diff --git a/.gitignore b/.gitignore index 1508fac..f907a5d 100644 --- a/.gitignore +++ b/.gitignore @@ -89,5 +89,8 @@ target/ examples/test_data/ examples/test_results/ +#logs +examples/logs/ + diff --git a/examples/ganrec_tf.ipynb b/examples/ganrec_tf.ipynb new file mode 100644 index 0000000..12d21e9 --- /dev/null +++ b/examples/ganrec_tf.ipynb @@ -0,0 +1,190 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "668c1d3f", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-16 16:32:34.441850: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.\n", + "To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "import tifffile\n", + "from ganrectf.utils import angles, nor_tomo, nor_phase\n", + "from ganrectf.ganrec import GANtomo, GANphase" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "4318235d", + "metadata": {}, + "outputs": [], + "source": [ + "prj = tifffile.imread('./test_data/tooth.tiff')\n", + "nang, px = prj.shape\n", + "ang = angles(nang)\n", + "# prj = nor_tomo(prj)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "eda65f88", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
Step 2000/2000 (100%)
\n", + "G_loss: 1.9143   D_loss: 1.3769   SSIM: 0.9799\n", + "
\n", + "
Reconstruction
1
0
\n", + "
\n", + "
\n", + "
Input
1
0
\n", + "
Predicted
1
0
\n", + "
\n", + "0499999149919991.3312.223.134StepLossG: 1.9143D: 1.3769" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "gan = GANtomo(prj, ang,\n", + " iter_num=2000,\n", + " l1_ratio=100,\n", + " g_learning_rate=1e-4,\n", + " recon_monitor=True)\n", + "recon = gan.recon()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "fef942d6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
Step 2000/2000 (100%)
\n", + "G_loss: 8.4757   D_loss: 0.6371   SSIM: 0.9447\n", + "
\n", + "
Reconstruction
1
-0.391
\n", + "
\n", + "
\n", + "
Input
5.9
-5.51
\n", + "
Predicted
6.88
-5.88
\n", + "
\n", + "0499998149719960.41124.448.572.5StepLossG: 8.4757D: 0.6371" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "energy = 10 # keV\n", + "z = 0.5 # propagation distance (m)\n", + "pv = 5e-7 # pixel size (m)\n", + "abs_ratio = 0.5\n", + "\n", + "fname_data = './test_data/hologram_shepp.tiff'\n", + "data = tifffile.imread(fname_data)\n", + "\n", + "gan_phase = GANphase(data, energy, z, pv,\n", + " abs_ratio=abs_ratio,\n", + " iter_num=2000,\n", + " g_learning_rate=1e-4,\n", + " recon_monitor=True)\n", + "phase = gan_phase.recon()" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "13223052", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
Step 2000/2000 (100%)
\n", + "G_loss: 5.9965   D_loss: 1.1048   SSIM: 0.9576\n", + "
\n", + "
Reconstruction
1
-1.18
\n", + "
\n", + "
\n", + "
Input
6.39
-5.96
\n", + "
Predicted
7.11
-6.4
\n", + "
\n", + "0499999149919991.0824.848.672.3StepLossG: 5.9965D: 1.1048" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "energy = 11\n", + "z = 7.8880960e-2\n", + "pv = 1.04735263e-7\n", + "abs_ratio = 0.06\n", + "iter_num = 2000\n", + "g_learning_rate = 1e-3\n", + "d_learning_rate = 1e-6\n", + "fname_data = './test_data/hologram_spider_hair.tiff'\n", + "data = tifffile.imread(fname_data)\n", + "\n", + "gan_phase = GANphase(data, energy, z, pv,\n", + " abs_ratio=abs_ratio,\n", + " iter_num=iter_num,\n", + " g_learning_rate=g_learning_rate,\n", + " d_learning_rate=d_learning_rate,\n", + " recon_monitor=True)\n", + "phase = gan_phase.recon()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "default", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ganrectf/config.json b/ganrectf/config.json index cadd333..595afe7 100644 --- a/ganrectf/config.json +++ b/ganrectf/config.json @@ -1,5 +1,5 @@ { - "GANtomo": { + "GANrec": { "iter_num": 1000, "conv_num": 32, "conv_size": 3, @@ -10,22 +10,20 @@ "save_wpath": null, "init_wpath": null, "init_model": false, - "recon_monitor": true + "recon_monitor": true, + "ema_decay": 0.99, + "snapshot_every": 50, + "disk_save_every": 100, + "log_every": 10, + "spike_factor": 1.5, + "warmup_ratio": 0.05, + "lr_backoff": 0.5, + "lr_floor": 1e-6, + "freeze_disc_max": 10, + "rescale_range": null, + "auto_scale": true }, - "GANtensor": { - "iter_num": 1000, - "conv_num": 32, - "conv_size": 3, - "dropout": 0.25, - "l1_ratio": 100, - "g_learning_rate": 1e-3, - "d_learning_rate": 1e-5, - "save_wpath": null, - "init_wpath": null, - "init_model": false, - "recon_monitor": true - }, "GANphase": { "iter_num": 1000, "conv_num": 32, @@ -41,19 +39,14 @@ "init_model": false, "recon_monitor": true }, + "GANdiffraction": { - "iter_num": 1000, - "conv_num": 32, - "conv_size": 3, - "dropout": 0.25, "l1_ratio": 500, "abs_ratio": 0.0, - "g_learning_rate": 1e-3, "d_learning_rate": 1e-6, - "phase_only": true, - "save_wpath": null, - "init_wpath": null, - "init_model": false, - "recon_monitor": true + "phase_only": true + }, + + "GANtensor": { } } diff --git a/ganrectf/ganrec.py b/ganrectf/ganrec.py index 4002ddc..705383b 100644 --- a/ganrectf/ganrec.py +++ b/ganrectf/ganrec.py @@ -6,12 +6,36 @@ from ganrectf.propagators import TomoRadon, TensorRadon, PhaseFresnel, PhaseFraunhofer from ganrectf.models import make_generator, make_discriminator from ganrectf.utils import RECONmonitor, ffactor +from ganrectf.tfutils import ( + normalize_to_target_range, tfnor_tomo, tfnor_phase, + flat_output, DeviceEMA, DeviceSnapshot, ScalarEMA, +) +from ganrectf.loss import generator_loss, discriminator_loss def tf_configures(): os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" +def _try_xla(): + """Return True if XLA JIT compilation is available.""" + try: + tf.function(lambda x: x + 1, jit_compile=True)(tf.constant(0.0)) + return True + except Exception: + return False + + +_XLA_OK = None # lazy singleton + + +def xla_available(): + global _XLA_OK + if _XLA_OK is None: + _XLA_OK = _try_xla() + return _XLA_OK + + # Load the configuration from the JSON file def load_config(filename): # Get the directory of the script @@ -28,591 +52,654 @@ def load_config(filename): # Use the configuration config = load_config("config.json") -@tf.function -def normalize_to_target_range( - generated: tf.Tensor, - target: tf.Tensor, - axis=None, - epsilon: float = 1e-8 -) -> tf.Tensor: - """ - Linearly rescales `generated` so that its [min, max] equals - the [min, max] of `target`, even if those values are negative. - If `generated` is constant (min == max), it will be mapped - to the midpoint of the `target` range. - - Args: - generated: tf.Tensor of any shape. - target: tf.Tensor broadcastable to generated. - axis: Dimensions to reduce over when computing min/max - (e.g. [1,2,3] to keep batch dim). None = all elements. - epsilon: Small constant to avoid divide-by-zero. - - Returns: - A tf.Tensor same shape as `generated`, but with - min→min(target) and max→max(target). - """ - # 1) compute per-sample minima and maxima - gen_min = tf.reduce_min(generated, axis=axis, keepdims=True) - gen_max = tf.reduce_max(generated, axis=axis, keepdims=True) - tar_min = tf.reduce_min(target, axis=axis, keepdims=True) - tar_max = tf.reduce_max(target, axis=axis, keepdims=True) - - # 2) compute ranges - gen_range = gen_max - gen_min - tar_range = tar_max - tar_min - - # 3) avoid zero division: if gen_range is too small, replace it with 1 - safe_gen_range = tf.where( - gen_range < epsilon, - tf.ones_like(gen_range), - gen_range - ) - - # 4) normalize to [0,1], then scale to [tar_min, tar_max] - normalized = (generated - gen_min) / safe_gen_range - scaled = normalized * tar_range + tar_min - - # 5) for constant inputs, map to target midpoint - target_mid = (tar_min + tar_max) * 0.5 - scaled = tf.where(gen_range < epsilon, target_mid, scaled) - return scaled - -@tf.function -def tfnor_tomo(img: tf.Tensor, eps: float = 1e-8) -> tf.Tensor: - """ - Standardizes input (zero-mean, unit-variance) and shifts to non-negative range. - Supports both 2D images and 3D volumes. - - Args: - img: 4D Tensor [B, H, W, C] for images or - 5D Tensor [B, D, H, W, C] for volumes. - eps: Small constant to avoid divide-by-zero. - Returns: - Tensor of same shape as img, with per-sample standardized values shifted - so that the minimum is 0. +class GANrec: + """ + General-purpose GAN-based reconstruction with full stability features. + + Unlike specialized classes (GANtomo, GANphase, etc.), GANrec is not restricted + to a specific forward model. The user provides a ``forward_fn`` callable that + defines the physics mapping from reconstruction space to measurement space. + + Stability features: + EMA of generator weights, on-device rollback snapshots, NaN/Inf guards, + loss-spike detection, discriminator freezing, learning-rate backoff, + cosine-decay learning-rate schedule, best-loss auto-restore. + + Parameters + ---------- + input_data : ndarray (2-D) + Measured data (sinogram, intensity image, diffraction pattern, ...). + forward_fn : callable + ``(gen_output, input_tensor) -> dict`` + Called inside ``@tf.function``; **must use TF operations only**. + The returned dict must contain at least: + + * ``"predicted"`` -- simulated measurement, same shape as *input_tensor*. + + It may include additional keys (``"recon"``, ``"phase"``, ...) that are + carried through for monitoring and final-output extraction. + output_num : int, optional + Number of generator output channels (default 1). + output_key : str, optional + Key in the ``forward_fn`` result dict to use as the final + reconstruction output (default ``"recon"``). + shape_output : tuple, optional + Shape to reshape the final output into. + Defaults to ``(input_data.shape[-1], input_data.shape[-1])``. + monitor_type : str, optional + Passed to ``RECONmonitor``: ``"tomo"``, ``"phase"``, or ``"tensor"`` + (default ``"tomo"``). + **kwargs + Override any default config value (``iter_num``, ``l1_ratio``, + ``g_learning_rate``, ``d_learning_rate``, ``conv_num``, ...). + + Examples + -------- + **Tomography**:: + + angle_tf = tf.cast(angles, tf.float32) + + def tomo_forward(gen_output, input_tensor): + recon = tfnor_tomo(gen_output) + prj_rec = TomoRadon(recon, angle_tf).compute() + prj_rec = normalize_to_target_range(prj_rec, input_tensor) + return {"recon": recon, "predicted": prj_rec} + + gan = GANrec(sinogram, tomo_forward) + result = gan.recon() + + **Phase retrieval (Fresnel)**:: + + ff = ffactor(img_w * 2, energy, z, pv) + + def phase_forward(gen_output, input_tensor): + phase = tfnor_phase(gen_output[:, :, :, 0]) + phase = tf.reshape(phase, [img_h, img_w]) + absorption = (1 - tfnor_phase(gen_output[:, :, :, 1])) * abs_ratio + absorption = tf.reshape(absorption, [img_h, img_w]) + i_rec = PhaseFresnel(phase, absorption, ff, img_w).compute() + return {"phase": phase, "absorption": absorption, "predicted": i_rec} + + gan = GANrec(intensity, phase_forward, output_num=2, + output_key="phase", monitor_type="phase") + result = gan.recon() """ - # Determine reduction axes based on tensor rank - ndim = img.shape.ndims - if ndim == 4: - # [batch, height, width, channels] - axes = [1, 2, 3] - elif ndim == 5: - # [batch, depth, height, width, channels] - axes = [1, 2, 3, 4] - else: - # fallback: normalize over all elements - axes = None - - # Compute per-sample mean and std - mean = tf.reduce_mean(img, axis=axes, keepdims=True) - std = tf.math.reduce_std(img, axis=axes, keepdims=True) - # Standardize - img_norm = (img - mean) / (std + eps) - - # Shift so minimum becomes 0 - min_val = tf.reduce_min(img_norm, axis=axes, keepdims=True) - img_pos = (img_norm - min_val)/ (tf.reduce_max(img_norm - min_val) + eps) - - return img_pos - -# @tf.function -def discriminator_loss(real_output, fake_output): - real_loss = tf.reduce_mean( - tf.nn.sigmoid_cross_entropy_with_logits(logits=real_output, labels=tf.ones_like(real_output)) - ) - fake_loss = tf.reduce_mean( - tf.nn.sigmoid_cross_entropy_with_logits(logits=fake_output, labels=tf.zeros_like(fake_output)) - ) - total_loss = real_loss + fake_loss - return total_loss - - -def l1_loss(img1, img2): - return tf.reduce_mean(tf.abs(img1 - img2)) - - -def l2_loss(img1, img2): - return tf.square(tf.reduce_mean(tf.abs(img1 - img2))) - - -# @tf.function -def generator_loss(fake_output, img_output, pred, l1_ratio): - gen_loss = ( - tf.reduce_mean( - tf.nn.sigmoid_cross_entropy_with_logits(logits=fake_output, labels=tf.ones_like(fake_output)) - ) - + l1_loss(img_output, pred) * l1_ratio - ) - return gen_loss - - -def tfnor_phase(img): - img = tf.image.per_image_standardization(img) - img = img / tf.reduce_max(img) - return img - - -def avg_results(recon, loss): - sort_index = np.argsort(loss) - recon_tmp = recon[sort_index[:10], :, :, :] - return np.mean(recon_tmp, axis=0) - -# compute every log_every steps -def flat_output(y, thr=1e-4): - # y in [-1,1]; thr is very small variance - v = tf.math.reduce_variance(y) - return bool((v < thr).numpy()) - -def set_lr(opt, new_lr: float): - try: - opt.learning_rate.assign(new_lr) - except Exception: - opt.learning_rate = new_lr - -class DeviceEMA: - """EMA on device: shadow Variables match model.trainable_variables.""" - def __init__(self, model, decay=0.999): - self.model = model - self.decay = tf.constant(decay, tf.float32) - self.src = list(model.trainable_variables) - self.shadow = [tf.Variable(v, trainable=False) for v in self.src] - self.backup = [tf.Variable(v, trainable=False) for v in self.src] - - @tf.function(reduce_retracing=True) # keep this compiled, it's called every step - def update(self): - for s, v in zip(self.shadow, self.src): - s.assign(self.decay * s + (1.0 - self.decay) * v) - - # NOTE: no @tf.function here — avoid retracing warnings - def swap_in(self): - # backup current to self.backup, then load shadow into model - for b, v in zip(self.backup, self.src): - b.assign(v) - for v, s in zip(self.src, self.shadow): - v.assign(s) - - # NOTE: no @tf.function here — avoid retracing warnings - def swap_out(self): - # restore from backup into model - for v, b in zip(self.src, self.backup): - v.assign(b) - -class DeviceSnapshot: - """On-device snapshot/restore of variables (no host copies, no tf.function).""" - def __init__(self, var_lists): - # var_lists is e.g. [gen.trainable_variables, disc.trainable_variables] - self.targets = [list(vs) for vs in var_lists] - self.copies = [[tf.Variable(v, trainable=False) for v in vs] - for vs in self.targets] - - # No @tf.function here: avoid retracing - def snapshot(self): - for copies, targets in zip(self.copies, self.targets): - for c, v in zip(copies, targets): - c.assign(v) - - # No @tf.function here: avoid retracing - def restore(self): - for copies, targets in zip(self.copies, self.targets): - for v, c in zip(targets, copies): - v.assign(c) - -class ScalarEMA: - """Scalar EMA without host sync.""" - def __init__(self, decay=0.95): - self.decay = tf.constant(decay, tf.float32) - self.value = tf.Variable(0.0, trainable=False, dtype=tf.float32) - self.has = tf.Variable(False, trainable=False, dtype=tf.bool) - @tf.function - def update(self, x): - x = tf.cast(x, tf.float32) - def _first(): - self.value.assign(x); self.has.assign(True) - return self.value - def _upd(): - self.value.assign(self.decay * self.value + (1.0 - self.decay) * x) - return self.value - return tf.cond(self.has, _upd, _first) - - -class GANtomo: - def __init__(self, prj_input, angle, **kwargs): - tomo_args = config["GANtomo"] - tomo_args.update(**kwargs) - super(GANtomo, self).__init__() + def __init__(self, input_data, forward_fn, output_num=1, output_key="recon", + shape_output=None, monitor_type="tomo", **kwargs): + base_args = config["GANrec"].copy() + base_args.update(kwargs) + super().__init__() tf_configures() - self.prj_input = prj_input - self.shape_input = self.prj_input.shape - self.shape_output = (self.shape_input[1], self.shape_input[1]) - self.angle = angle - self.iter_num = tomo_args["iter_num"] - self.conv_num = tomo_args["conv_num"] - self.conv_size = tomo_args["conv_size"] - self.dropout = tomo_args["dropout"] - self.l1_ratio = tomo_args["l1_ratio"] - self.g_learning_rate = tomo_args["g_learning_rate"] - self.d_learning_rate = tomo_args["d_learning_rate"] - self.save_wpath = tomo_args["save_wpath"] - self.init_wpath = tomo_args["init_wpath"] - self.init_model = tomo_args["init_model"] - self.recon_monitor = tomo_args["recon_monitor"] + self.input_data = input_data + self.forward_fn = forward_fn + self.output_num = output_num + self.output_key = output_key + self.shape_input = input_data.shape + self.shape_output = shape_output or (self.shape_input[-1], self.shape_input[-1]) + self.monitor_type = monitor_type + + self.iter_num = base_args["iter_num"] + self.conv_num = base_args["conv_num"] + self.conv_size = base_args["conv_size"] + self.dropout = base_args["dropout"] + self.l1_ratio = base_args["l1_ratio"] + self.g_learning_rate = base_args["g_learning_rate"] + self.d_learning_rate = base_args["d_learning_rate"] + self.save_wpath = base_args["save_wpath"] + self.init_wpath = base_args["init_wpath"] + self.init_model = base_args["init_model"] + self.recon_monitor = base_args["recon_monitor"] + self.ema_decay = base_args.get("ema_decay", 0.99) + self.snapshot_every = base_args.get("snapshot_every", 50) + self.disk_save_every = base_args.get("disk_save_every", 100) + self.log_every = base_args.get("log_every", 10) + self.spike_factor = base_args.get("spike_factor", 1.5) + self.warmup_ratio = base_args.get("warmup_ratio", 0.05) + self.lr_backoff = base_args.get("lr_backoff", 0.5) + self.lr_floor = base_args.get("lr_floor", 1e-6) + self.freeze_disc_max = base_args.get("freeze_disc_max", 10) + self.rescale_range = base_args.get("rescale_range", None) + self.auto_scale = base_args.get("auto_scale", True) + self._data_min = float(np.min(input_data)) + self._data_max = float(np.max(input_data)) + # Pre-scale input to [0,1] for well-conditioned training + if self.auto_scale: + drange = self._data_max - self._data_min + if drange > 1e-12: + self._scale = drange + self._offset = self._data_min + self.input_data = (input_data - self._offset) / self._scale + else: + self._scale = 1.0 + self._offset = 0.0 + else: + self._scale = 1.0 + self._offset = 0.0 self._make_model() def _make_model(self): - self.generator = make_generator(self.shape_input, - self.conv_num, - self.conv_size, - self.dropout, 1) + self.generator = make_generator( + self.shape_input, self.conv_num, self.conv_size, + self.dropout, self.output_num + ) self.discriminator = make_discriminator(self.shape_input) - self.generator_optimizer = tf.keras.optimizers.AdamW(self.g_learning_rate, - weight_decay=1e-4, - beta_2=0.99, - clipnorm=1.0) - self.discriminator_optimizer = tf.keras.optimizers.AdamW(self.d_learning_rate, - weight_decay=1e-4, - beta_2=0.99, - clipnorm=1.0) - self.generator.compile() - self.discriminator.compile() - # @tf.function - # def tfnor_tomo(self, img): - # img = tf.image.per_image_standardization(img) - # img = (img - tf.reduce_min(img)) / (tf.reduce_max(img) - tf.reduce_min(img)) - # return img + # Cosine-decay LR schedules for smoother convergence + g_lr_schedule = tf.keras.optimizers.schedules.CosineDecay( + initial_learning_rate=self.g_learning_rate, + decay_steps=int(self.iter_num), + alpha=self.lr_floor / max(self.g_learning_rate, 1e-12), + ) + d_lr_schedule = tf.keras.optimizers.schedules.CosineDecay( + initial_learning_rate=self.d_learning_rate, + decay_steps=int(self.iter_num), + alpha=self.lr_floor / max(self.d_learning_rate, 1e-12), + ) - @tf.function - def recon_step(self, prj, ang): - with tf.GradientTape() as gen_tape, tf.GradientTape() as disc_tape: - recon = self.generator(prj) - recon = tfnor_tomo(recon) - tomo_radon_obj = TomoRadon(recon, ang) - prj_rec = tomo_radon_obj.compute() - prj_rec = normalize_to_target_range(prj_rec, prj) - real_output = self.discriminator(prj, training=True) - fake_output = self.discriminator(prj_rec, training=True) - g_loss = generator_loss(fake_output, prj, prj_rec, self.l1_ratio) - d_loss = discriminator_loss(real_output, fake_output) - gradients_of_generator = gen_tape.gradient(g_loss, self.generator.trainable_variables) - gradients_of_discriminator = disc_tape.gradient(d_loss, self.discriminator.trainable_variables) - self.generator_optimizer.apply_gradients(zip(gradients_of_generator, self.generator.trainable_variables)) - self.discriminator_optimizer.apply_gradients( - zip(gradients_of_discriminator, self.discriminator.trainable_variables) + self.generator_optimizer = tf.keras.optimizers.AdamW( + learning_rate=g_lr_schedule, + weight_decay=1e-4, beta_2=0.99, clipnorm=1.0, ) - return {"recon": recon, "prj_rec": prj_rec, "g_loss": g_loss, "d_loss": d_loss} - - def recon(self, prj_input=None): + self.discriminator_optimizer = tf.keras.optimizers.AdamW( + learning_rate=d_lr_schedule, + weight_decay=1e-4, beta_2=0.99, clipnorm=1.0, + ) + self.generator.compile(jit_compile=xla_available()) + self.discriminator.compile(jit_compile=xla_available()) + self.g_optimizer = self.generator_optimizer + self.d_optimizer = self.discriminator_optimizer + # On-device flag: when True, discriminator gradients are not applied + self._freeze_disc = tf.Variable(False, trainable=False, dtype=tf.bool) + + def _rescale_output(self, arr): + """Rescale reconstruction output to a target value range. + + Parameters + ---------- + arr : ndarray + Raw (normalized) reconstruction output. + + Returns + ------- + ndarray + Rescaled array. Unchanged when ``rescale_range`` is *None*. + + Notes + ----- + ``rescale_range`` accepts: + + * ``None`` – no rescaling (backward-compatible default). + * ``"input"`` – linearly map to ``[input_min, input_max]``. + * ``[vmin, vmax]`` – linearly map to a user-specified range. """ - Fast & stable reconstruction loop: - - On-device EMA & rollback snapshots (no host copies) - - Rare disk saves (weights-only) instead of per-step saves - - Minimal host sync (log every k steps) - - Brief D 'freeze' by zeroing LR (no retracing) - Expects: self.generator, self.discriminator, self.recon_step(prj, ang), - self.iter_num, self.angle, self.prj_input, self.shape_output. - Optional: self.g_optimizer, self.d_optimizer, self.recon_monitor, self.save_wpath, self.init_wpath. + mode = self.rescale_range + if mode is None: + return arr + if isinstance(mode, str) and mode == "input": + target_min, target_max = self._data_min, self._data_max + elif isinstance(mode, (list, tuple)) and len(mode) == 2: + target_min, target_max = float(mode[0]), float(mode[1]) + else: + return arr + arr_min, arr_max = float(arr.min()), float(arr.max()) + if arr_max - arr_min < 1e-12: + return np.full_like(arr, (target_min + target_max) / 2.0) + return ((arr - arr_min) / (arr_max - arr_min) + * (target_max - target_min) + target_min) + + @tf.function(reduce_retracing=True) + def recon_step(self, input_tensor): + # Single persistent tape avoids building two independent tape contexts + with tf.GradientTape(persistent=True) as tape: + gen_output = self.generator(input_tensor, training=True) + fwd = self.forward_fn(gen_output, input_tensor) + predicted = fwd["predicted"] + real_output = self.discriminator(input_tensor, training=True) + fake_output = self.discriminator(predicted, training=True) + recon = fwd.get("recon", gen_output) + g_loss = generator_loss(fake_output, input_tensor, predicted, + recon, self.l1_ratio) + d_loss = discriminator_loss(real_output, fake_output) + + gen_grads = tape.gradient(g_loss, self.generator.trainable_variables) + disc_grads = tape.gradient(d_loss, self.discriminator.trainable_variables) + del tape # free persistent tape memory immediately + + # Clip gradients by global norm for extra stability + gen_grads, g_gnorm = tf.clip_by_global_norm(gen_grads, 5.0) + disc_grads, d_gnorm = tf.clip_by_global_norm(disc_grads, 5.0) + + self.generator_optimizer.apply_gradients( + zip(gen_grads, self.generator.trainable_variables) + ) + # Skip discriminator update when frozen (spike/NaN recovery) + def _apply_disc(): + self.discriminator_optimizer.apply_gradients( + zip(disc_grads, self.discriminator.trainable_variables) + ) + return tf.constant(0) + + tf.cond( + self._freeze_disc, + lambda: tf.constant(0), + _apply_disc, + ) + + fwd["g_loss"] = g_loss + fwd["d_loss"] = d_loss + fwd["g_gnorm"] = g_gnorm + fwd["d_gnorm"] = d_gnorm + return fwd + + @tf.function(reduce_retracing=True) + def _check_health(self, g_loss, d_loss): + """On-device NaN/Inf check — avoids per-step .numpy() host sync.""" + finite = tf.math.is_finite(g_loss) & tf.math.is_finite(d_loss) + return finite + + def recon(self, input_data=None): + """Run the full reconstruction loop with stability safeguards. + + Parameters + ---------- + input_data : ndarray, optional + Override the input data provided at construction time. + + Returns + ------- + ndarray + Reconstruction reshaped to ``shape_output``. """ # ---------- Optional initial load ---------- if getattr(self, "init_wpath", None): try: - self.generator.load_weights(os.path.join(self.init_wpath, "generator.keras")) - self.discriminator.load_weights(os.path.join(self.init_wpath, "discriminator.keras")) + self.generator.load_weights( + os.path.join(self.init_wpath, "generator.keras")) + self.discriminator.load_weights( + os.path.join(self.init_wpath, "discriminator.keras")) print("Models are initialized") except Exception as e: print(f"[init] load failed: {e}") # ---------- Inputs ---------- - if prj_input is not None: - self.prj_input = prj_input - prj = tf.cast(self.prj_input, tf.float32)[None, ..., None] - ang = tf.cast(self.angle, tf.float32) - self.t = tf.random.normal((1, 500)) - - # ---------- Tunables (you can override on self.*) ---------- - ema_decay = float(getattr(self, "ema_decay", 0.99)) - snapshot_every = int(getattr(self, "snapshot_every", 50)) # on-device RAM snapshot - disk_save_every = int(getattr(self, "disk_save_every", 100)) # rare weights-only to disk - log_every = int(getattr(self, "log_every", 10)) # host sync for logs/plots - monitor_every = int(getattr(self, "monitor_every", 100)) # plotting interval - spike_factor = float(getattr(self, "spike_factor", 1.5)) - warmup_steps = int(getattr(self, "warmup_steps", max(5, int(self.iter_num)//20))) - lr_backoff = float(getattr(self, "lr_backoff", 0.5)) - lr_floor = float(getattr(self, "lr_floor", 1e-6)) - freeze_disc_max = int(getattr(self, "freeze_disc_max", 10)) - weights_dir = getattr(self, "save_wpath", None) - - # ---------- EMA & Snapshots (on device) ---------- + if input_data is not None: + self.input_data = input_data + # tf.constant avoids re-tracing: the tensor is baked as a graph constant + input_tensor = tf.constant( + self.input_data.astype(np.float32)[None, ..., None] + ) + + # ---------- Tunables ---------- + ema_decay = float(self.ema_decay) + snapshot_every = int(self.snapshot_every) + disk_save_every = int(self.disk_save_every) + log_every = int(self.log_every) + spike_factor = float(self.spike_factor) + warmup_steps = max(5, int(self.iter_num * self.warmup_ratio)) + lr_backoff = float(self.lr_backoff) + lr_floor = float(self.lr_floor) + freeze_disc_max = int(self.freeze_disc_max) + weights_dir = self.save_wpath + total_steps = int(self.iter_num) + + # ---------- EMA & Snapshots ---------- ema = DeviceEMA(self.generator, decay=ema_decay) - snap = DeviceSnapshot([self.generator.trainable_variables, - self.discriminator.trainable_variables]) - snap.snapshot() # initial good state + snap = DeviceSnapshot([ + self.generator.trainable_variables, + self.discriminator.trainable_variables, + ]) + snap.snapshot() + + # Best-loss tracking: auto-restore to the best-seen weights + best_snap = DeviceSnapshot([ + self.generator.trainable_variables, + self.discriminator.trainable_variables, + ]) + best_g_loss = float("inf") + best_snap.snapshot() - # Scalar EMAs for anomaly detection (no host sync) g_ema = ScalarEMA(0.95) d_ema = ScalarEMA(0.95) - # Keep original learning rates to restore after temporary freezes - g_lr0 = (float(tf.keras.backend.get_value(self.g_optimizer.learning_rate)) - if hasattr(self, "g_optimizer") else None) - d_lr0 = (float(tf.keras.backend.get_value(self.d_optimizer.learning_rate)) - if hasattr(self, "d_optimizer") else None) - - # ---------- Monitor ---------- - pbar = tqdm(total=int(self.iter_num), desc="Reconstruction", leave=True) - + # ---------- Monitor (TensorBoard) ---------- + recon_monitor = None if self.recon_monitor: - recon_monitor = RECONmonitor("tomo", self.prj_input) + recon_monitor = RECONmonitor(self.monitor_type, self.input_data) + recon_monitor.total_steps = total_steps + + # Use tqdm only when monitor is not handling inline notebook display + use_tqdm = not (recon_monitor is not None and recon_monitor._in_notebook) + if use_tqdm: + pbar = tqdm(total=total_steps, desc="Reconstruction", leave=True) + else: + pbar = None + freeze_disc_steps = 0 - last_recon_np = None # only updated occasionally to avoid host sync step_result = {} - for step in range(int(self.iter_num)): - # Apply temporary D freeze by LR=0 (no retracing) - if hasattr(self, "d_optimizer") and d_lr0 is not None: - if freeze_disc_steps > 0: - set_lr(self.d_optimizer, 0.0) - elif float(tf.keras.backend.get_value(self.d_optimizer.learning_rate)) == 0.0: - set_lr(self.d_optimizer, d_lr0) + for step in range(total_steps): + # D freeze management + if freeze_disc_steps > 0: + self._freeze_disc.assign(True) + else: + self._freeze_disc.assign(False) - # One step — your function should do forward/backward/updates (prefer @tf.function in it) - step_result = self.recon_step(prj, ang) - if "recon" in step_result and (step % log_every == 0): - y = step_result["recon"] - if flat_output(y): - # 1) roll back to last good snapshot (you already have DeviceSnapshot.restore()) - snap.restore() - # 2) reduce GAN pressure and boost pixel losses - if hasattr(self, "lambda_gan"): self.lambda_gan *= 0.5 - # 3) small LR backoff for D; keep or raise G LR slightly - if hasattr(self, "d_optimizer"): - cur = float(tf.keras.backend.get_value(self.d_optimizer.learning_rate)) - set_lr(self.d_optimizer, max(1e-6, 0.5 * cur)) - - g_loss_t = tf.cast(step_result["g_loss"], tf.float32) - d_loss_t = tf.cast(step_result["d_loss"], tf.float32) - - # Guard: NaN/Inf - finite = tf.math.is_finite(g_loss_t) & tf.math.is_finite(d_loss_t) - if not bool(finite.numpy()): # scalar sync (cheap) - snap.restore() # instant on-device rollback - if hasattr(self, "g_optimizer") and g_lr0 is not None: - cur = float(tf.keras.backend.get_value(self.g_optimizer.learning_rate)) - set_lr(self.g_optimizer, max(lr_floor, cur * lr_backoff)) - if hasattr(self, "d_optimizer") and d_lr0 is not None: - cur = float(tf.keras.backend.get_value(self.d_optimizer.learning_rate)) - set_lr(self.d_optimizer, max(lr_floor, cur * lr_backoff)) + # ---- forward / backward / update ---- + step_result = self.recon_step(input_tensor) + + g_loss_t = step_result["g_loss"] + d_loss_t = step_result["d_loss"] + + # On-device finiteness check (only sync to host on failure) + finite = self._check_health(g_loss_t, d_loss_t) + if not bool(finite): + snap.restore() freeze_disc_steps = freeze_disc_max - if step % log_every == 0: - pbar.set_postfix_str("NaN/Inf→rollback") - pbar.update(1) + if pbar and step % log_every == 0: + pbar.set_postfix_str("NaN/Inf->rollback") + if recon_monitor is not None: + recon_monitor.step += 1 + if pbar: + pbar.update(1) continue - # Update scalar EMAs (device) - g_ema_v = g_ema.update(g_loss_t) - d_ema_v = d_ema.update(d_loss_t) - - # Spike detection (occasional host fetch for decision) - do_check = (step > warmup_steps) and (step % log_every == 0) - if do_check: - g_loss = float(g_loss_t.numpy()) - d_loss = float(d_loss_t.numpy()) - g_bar = float(g_ema_v.numpy()) - d_bar = float(d_ema_v.numpy()) - if g_loss > spike_factor * max(1e-8, g_bar) or d_loss > spike_factor * max(1e-8, d_bar): - snap.restore() - if hasattr(self, "g_optimizer") and g_lr0 is not None: - cur = float(tf.keras.backend.get_value(self.g_optimizer.learning_rate)) - set_lr(self.g_optimizer, max(lr_floor, cur * lr_backoff)) - if hasattr(self, "d_optimizer") and d_lr0 is not None: - cur = float(tf.keras.backend.get_value(self.d_optimizer.learning_rate)) - set_lr(self.d_optimizer, max(lr_floor, cur * lr_backoff)) - freeze_disc_steps = freeze_disc_max - pbar.set_postfix_str("spike→rollback") - pbar.update(1) - continue + # Periodic host sync for diagnostics (every log_every steps) + if step % log_every == 0: + g_val = float(g_loss_t.numpy()) + d_val = float(d_loss_t.numpy()) + + # Flat-output guard + if self.output_key in step_result: + if flat_output(step_result[self.output_key]): + snap.restore() + if pbar: + pbar.set_postfix_str("flat->rollback") + pbar.update(1) + if recon_monitor is not None: + recon_monitor.step += 1 + continue + + # Spike detection (after warmup) + g_ema_v = g_ema.update(g_loss_t) + d_ema_v = d_ema.update(d_loss_t) + if step > warmup_steps: + g_bar = float(g_ema_v.numpy()) + d_bar = float(d_ema_v.numpy()) + if (g_val > spike_factor * max(1e-8, g_bar) or + d_val > spike_factor * max(1e-8, d_bar)): + snap.restore() + freeze_disc_steps = freeze_disc_max + if pbar: + pbar.set_postfix_str("spike->rollback") + pbar.update(1) + if recon_monitor is not None: + recon_monitor.step += 1 + continue + + # Best-loss tracking + if g_val < best_g_loss: + best_g_loss = g_val + best_snap.snapshot() + + if pbar: + pbar.set_postfix( + G=f"{g_val:.4f}", + D=f"{d_val:.4f}", + gn=f"{float(step_result['g_gnorm'].numpy()):.1f}", + ) + else: + # Still update scalar EMAs on non-log steps (cheap, stays on device) + g_ema.update(g_loss_t) + d_ema.update(d_loss_t) - # Good step: update EMA weights and snapshot occasionally (device only) - ema.update() # on-device + # Good step -- EMA & snapshot + ema.update() if step % snapshot_every == 0: - snap.snapshot() # on-device, cheap + snap.snapshot() - # Rare disk save (weights-only) - if weights_dir and (step % disk_save_every == 0 or step == int(self.iter_num) - 1): + # Disk save (rare) + if weights_dir and (step % disk_save_every == 0 or + step == total_steps - 1): try: os.makedirs(weights_dir, exist_ok=True) - self.generator.save_weights(os.path.join(weights_dir, "generator.weights.h5")) - self.discriminator.save_weights(os.path.join(weights_dir, "discriminator.weights.h5")) + self.generator.save_weights( + os.path.join(weights_dir, "generator.weights.h5")) + self.discriminator.save_weights( + os.path.join(weights_dir, "discriminator.weights.h5")) except Exception as e: - if step % log_every == 0: + if pbar and step % log_every == 0: pbar.set_postfix_str(f"save err: {e}") - # Progress/logging (host sync every log_every steps only) - if step % log_every == 0: - pbar.set_postfix(G_loss=f"{float(g_loss_t.numpy()):.4f}", - D_loss=f"{float(d_loss_t.numpy()):.4f}") - - if self.recon_monitor: + # Monitor (TensorBoard summaries) + if recon_monitor is not None: recon_monitor.update_plot(step_result) - # countdown + if freeze_disc_steps > 0: freeze_disc_steps -= 1 + if pbar: + pbar.update(1) - pbar.update(1) - - pbar.close() - if getattr(self, "recon_monitor", False) and 'recon_monitor' in locals() and recon_monitor is not None: - try: recon_monitor.close_plot() - except Exception: pass + if pbar: + pbar.close() + if recon_monitor is not None: + try: + recon_monitor.close_plot(last_step_result=step_result) + except Exception: + pass - # Final output: evaluate with EMA weights (swap in/out on device, no host copy) + # ---------- Final output: restore best weights, then apply EMA ---------- + best_snap.restore() ema.swap_in() try: - # If your generator supports a direct forward to produce recon from (prj, ang), - # compute it here. Otherwise fall back to the last monitored recon. - final = last_recon_np - if final is None and "recon" in step_result: - final = step_result["recon"].numpy() - if final is None: - final = np.zeros(self.shape_output, dtype=np.float32) + gen_output = self.generator(input_tensor, training=False) + fwd = self.forward_fn(gen_output, input_tensor) + self._last_fwd = fwd + if self.output_key in fwd: + final = fwd[self.output_key].numpy() + else: + final = fwd["predicted"].numpy() finally: ema.swap_out() - return np.reshape(final.astype(np.float32), self.shape_output) + final = np.reshape(final.astype(np.float32), self.shape_output) + # Undo auto-scaling to restore original data range + if self.auto_scale: + final = final * self._scale + self._offset + return self._rescale_output(final) -class GANtensor: - def __init__(self, prj_input, angle, psi, **kwargs): - tomo_args = config["GANtensor"] - tomo_args.update(**kwargs) - super(GANtensor, self).__init__() - tf_configures() - self.prj_input = prj_input - self.img_h, self.img_w = self.prj_input.shape - self.angle = angle - self.psi = psi - self.iter_num = tomo_args["iter_num"] - self.conv_num = tomo_args["conv_num"] - self.conv_size = tomo_args["conv_size"] - self.dropout = tomo_args["dropout"] - self.l1_ratio = tomo_args["l1_ratio"] - self.g_learning_rate = tomo_args["g_learning_rate"] - self.d_learning_rate = tomo_args["d_learning_rate"] - self.save_wpath = tomo_args["save_wpath"] - self.init_wpath = tomo_args["init_wpath"] - self.init_model = tomo_args["init_model"] - self.recon_monitor = tomo_args["recon_monitor"] - self.filter = None - self.generator = None - self.discriminator = None - self.filter_optimizer = None - self.generator_optimizer = None - self.discriminator_optimizer = None +class GANtomo(GANrec): + """GAN-based tomographic reconstruction. - def make_model(self): - self.generator = make_generator( - self.prj_input.shape[0], self.prj_input.shape[1], self.conv_num, self.conv_size, self.dropout, 3 - ) - self.discriminator = make_discriminator(self.prj_input.shape[0], self.prj_input.shape[1]) - self.generator_optimizer = tf.keras.optimizers.Adam(self.g_learning_rate) - self.discriminator_optimizer = tf.keras.optimizers.Adam(self.d_learning_rate) - self.generator.compile() - self.discriminator.compile() + Wraps ``GANrec`` with a Radon-transform forward model. - @tf.function - # def tfnor_tomo(self, data): - # # Calculate the mean and standard deviation of the data - # mean = tf.reduce_mean(data) - # std = tf.math.reduce_std(data) - # # Standardize the data (z-score normalization) - # standardized_data = (data - mean) / std - # # Find the minimum value in the standardized data - # standardized_min = tf.reduce_min(standardized_data) - # # Shift the data to start from 0 - # shifted_data = standardized_data - standardized_min - # return shifted_data - - def tfnor_tomo(self, img): - img = tf.image.per_image_standardization(img) - img = (img - tf.reduce_min(img)) / (tf.reduce_max(img) - tf.reduce_min(img)) - return img + Parameters + ---------- + prj_input : ndarray (2-D) + Sinogram (angles x pixels). + angle : array-like + Projection angles in radians. + **kwargs + Forwarded to ``GANrec``. + """ - @tf.function - def recon_step(self, prj, ang, psi): - with tf.GradientTape() as gen_tape, tf.GradientTape() as disc_tape: - recon = self.generator(prj) - recon = self.tfnor_tomo(recon) - tomo_radon_obj = TensorRadon(recon, ang, psi) - prj_rec = tomo_radon_obj.compute() - prj_rec = self.tfnor_tomo(prj_rec) - real_output = self.discriminator(prj, training=True) - fake_output = self.discriminator(prj_rec, training=True) - g_loss = generator_loss(fake_output, prj, prj_rec, self.l1_ratio) - d_loss = discriminator_loss(real_output, fake_output) - gradients_of_generator = gen_tape.gradient(g_loss, self.generator.trainable_variables) - gradients_of_discriminator = disc_tape.gradient(d_loss, self.discriminator.trainable_variables) - self.generator_optimizer.apply_gradients(zip(gradients_of_generator, self.generator.trainable_variables)) - self.discriminator_optimizer.apply_gradients( - zip(gradients_of_discriminator, self.discriminator.trainable_variables) + def __init__(self, prj_input, angle, **kwargs): + angle_tf = tf.cast(angle, tf.float32) + + def forward_fn(gen_output, input_tensor): + recon = tfnor_tomo(gen_output) + prj_rec = TomoRadon(recon, angle_tf).compute() + prj_rec = normalize_to_target_range(prj_rec, input_tensor) + return {"recon": recon, "predicted": prj_rec} + + # Use normalization for training stability; rescale output to original range + kwargs.setdefault("rescale_range", "input") + super().__init__(prj_input, forward_fn, output_num=1, + output_key="recon", monitor_type="tomo", **kwargs) + + +class GANphase(GANrec): + """GAN-based phase retrieval (Fresnel propagation). + + Wraps ``GANrec`` with a Fresnel forward model. + + Parameters + ---------- + i_input : ndarray (2-D) + Intensity image. + energy : float + Beam energy (keV). + z : float + Propagation distance. + pv : float + Pixel size. + **kwargs + Forwarded to ``GANrec``. + """ + + def __init__(self, i_input, energy, z, pv, **kwargs): + phase_cfg = config.get("GANphase", {}).copy() + phase_cfg.update(kwargs) + + abs_ratio = phase_cfg.get("abs_ratio", 1.0) + phase_only = phase_cfg.get("phase_only", False) + img_h, img_w = i_input.shape + ff = ffactor(img_w * 2, energy, z, pv) + + def forward_fn(gen_output, input_tensor): + phase = tfnor_phase(gen_output[:, :, :, 0]) + phase = tf.reshape(phase, [img_h, img_w]) + if phase_only: + absorption = tf.zeros_like(phase) + else: + absorption = (1 - tfnor_phase(gen_output[:, :, :, 1])) * abs_ratio + absorption = tf.reshape(absorption, [img_h, img_w]) + i_rec = PhaseFresnel(phase, absorption, ff, img_w).compute() + i_rec = normalize_to_target_range(i_rec, input_tensor) + # Pass normalized phase as "recon" so generator_loss computes + # TV regularization on the phase (matching the original code), + # not on the raw multi-channel generator output. + recon = tf.reshape(phase, [1, img_h, img_w, 1]) + return {"phase": phase, "absorption": absorption, + "predicted": i_rec, "recon": recon} + kwargs.setdefault("rescale_range", "input") + super().__init__(i_input, forward_fn, output_num=2, + output_key="phase", monitor_type="phase", + **phase_cfg) + + +class GANdiffraction(GANrec): + """GAN-based diffraction pattern reconstruction (Fraunhofer propagation). + + Wraps ``GANrec`` with a Fraunhofer forward model. + + Parameters + ---------- + i_input : ndarray (2-D) + Measured diffraction pattern. + mask : ndarray or None + Binary mask applied to the simulated pattern. + **kwargs + Forwarded to ``GANrec``. + """ + + def __init__(self, i_input, mask, **kwargs): + diff_cfg = config.get("GANdiffraction", {}).copy() + diff_cfg.update(kwargs) + + abs_ratio = diff_cfg.get("abs_ratio", 1.0) + phase_only = diff_cfg.get("phase_only", False) + px = i_input.shape[0] + use_mask = mask is not None + if use_mask: + mask_tf = tf.constant(mask, dtype=tf.float32) + + def _tfnor_diff(img): + return (img - tf.reduce_min(img)) / (tf.reduce_max(img) - tf.reduce_min(img)) + + def forward_fn(gen_output, input_tensor): + phase = _tfnor_diff(gen_output[:, :, :, 0]) + phase = tf.reshape(phase, [px // 2, px // 2]) + phase = tf.pad(phase, [[64, 64], [64, 64]]) + absorption = (1 - _tfnor_diff(gen_output[:, :, :, 1])) * abs_ratio + absorption = tf.reshape(absorption, [px // 2, px // 2]) + absorption = tf.pad(absorption, [[64, 64], [64, 64]]) + if phase_only: + absorption = tf.zeros_like(phase) + i_rec = PhaseFraunhofer(phase, absorption).compute() + if use_mask: + mask_r = tf.reshape(mask_tf, [1, mask_tf.shape[0], mask_tf.shape[1], 1]) + i_rec = tf.multiply(i_rec, mask_r) + i_rec = _tfnor_diff(i_rec) + return {"phase": phase, "absorption": absorption, "predicted": i_rec} + + super().__init__(i_input, forward_fn, output_num=2, + output_key="phase", monitor_type="phase", + **diff_cfg) + + def recon(self, input_data=None): + """Run reconstruction and return ``(absorption, phase)``.""" + phase = super().recon(input_data) + absorption = np.reshape( + self._last_fwd["absorption"].numpy().astype(np.float32), + self.shape_output ) - return {"recon": recon, "prj_rec": prj_rec, "g_loss": g_loss, "d_loss": d_loss} + absorption = self._rescale_output(absorption) + return absorption, phase - @property - def recon(self): - nang, px = self.prj_input.shape - prj = np.reshape(self.prj_input, (1, nang, px, 1)) - prj = tf.cast(prj, dtype=tf.float32) - prj = self.tfnor_tomo(prj) - ang = tf.cast(self.angle, dtype=tf.float32) - psi = self.psi - self.make_model() - if self.init_wpath: - self.generator.load_weights(self.init_wpath + "generator.h5") - print("generator is initilized") - self.discriminator.load_weights(self.init_wpath + "discriminator.h5") - # recon = np.zeros((self.iter_num, px, px, 6)) - # gen_loss = np.zeros((self.iter_num)) - ########################################################################### - # Reconstruction process monitor - pbar = tqdm(total=self.iter_num, desc="Reconstruction Progress", position=0, leave=True) - if self.recon_monitor: - recon_monitor = RECONmonitor("tensor", self.prj_input) - # plot_x, plot_loss = [], [] - # recon_monitor = RECONmonitor("tomo") - # recon_monitor.initial_plot(self.prj_input) - # pbar = tqdm(total=self.iter_num, desc="Reconstruction Progress", position=0, leave=True) - ########################################################################### - for epoch in range(self.iter_num): - step_result = self.recon_step(prj, ang, psi) - # recon[epoch, :, :, :] = step_result["recon"] - pbar.set_postfix(G_loss=step_result["g_loss"].numpy(), D_loss=step_result["d_loss"].numpy()) - pbar.update(1) - # gen_loss[epoch] = step_result["g_loss"] +class GANtensor(GANrec): + """GAN-based tensor tomographic reconstruction. - ########################################################################### - if self.recon_monitor: - recon_monitor.update_plot(step_result) - # if (epoch + 1) % 100 == 0: - # if recon_monitor: - # prj_rec = np.reshape(step_result["prj_rec"], (nang, px)) - # prj_diff = np.abs(prj_rec - self.prj_input.reshape((nang, px))) - # rec_plt = np.reshape(recon[epoch, :, :, 0], (px, px)) - # recon_monitor.update_plot(epoch, prj_diff, rec_plt, plot_x, plot_loss) - if self.save_wpath != None: - self.generator.save(self.save_wpath + "generator.h5") - self.discriminator.save(self.save_wpath + "discriminator.h5") - recon_monitor.close_plot() - - recon_out = np.transpose(step_result['recon'][0].numpy(), axes=(2, 0, 1)) - return recon_out.astype(np.float32) + Wraps ``GANrec`` with a tensor Radon-transform forward model. + + Parameters + ---------- + prj_input : ndarray (2-D) + Sinogram (angles x pixels). + angle : array-like + Projection angles in radians. + psi : array-like + Rotation angles for tensor tomography. + **kwargs + Forwarded to ``GANrec``. + """ + + def __init__(self, prj_input, angle, psi, **kwargs): + tensor_cfg = config.get("GANtensor", {}).copy() + tensor_cfg.update(kwargs) + + angle_tf = tf.cast(angle, tf.float32) + px = prj_input.shape[1] + + def _tensor_norm(img): + img = tf.image.per_image_standardization(img) + img = (img - tf.reduce_min(img)) / (tf.reduce_max(img) - tf.reduce_min(img)) + return img + + def forward_fn(gen_output, input_tensor): + recon = _tensor_norm(gen_output) + prj_rec = TensorRadon(recon, angle_tf, psi).compute() + prj_rec = _tensor_norm(prj_rec) + return {"recon": recon, "predicted": prj_rec} + + super().__init__(prj_input, forward_fn, output_num=3, + output_key="recon", + shape_output=(px, px, 3), + monitor_type="tensor", + **tensor_cfg) + + def recon(self, input_data=None): + """Run reconstruction and return tensor components as ``(C, H, W)``.""" + result = super().recon(input_data) + return np.transpose(result, axes=(2, 0, 1)) class GANtomo3D: def __init__(self, prj_input, angle, **kwargs): - tomo_args = config["GANphase"] + tomo_args = config["GANrec"].copy() tomo_args.update(**kwargs) super(GANtomo3D, self).__init__() self.prj_input = prj_input @@ -673,7 +760,7 @@ def recon_step(self, prj, ang): prj_rec = self.tfnor_tomo(prj_rec) real_output = self.discriminator(prj, training=True) fake_output = self.discriminator(prj_rec, training=True) - g_loss = generator_loss(fake_output, prj, prj_rec, self.l1_ratio) + g_loss = generator_loss(fake_output, prj, prj_rec, recon, self.l1_ratio) d_loss = discriminator_loss(real_output, fake_output) gradients_of_generator = gen_tape.gradient(g_loss, self.generator.trainable_variables) gradients_of_discriminator = disc_tape.gradient(d_loss, self.discriminator.trainable_variables) @@ -695,7 +782,7 @@ def recon_step_filter(self, prj, ang): real_output = self.discriminator(prj, training=True) filter_output = self.discriminator(prj_filter, training=True) fake_output = self.discriminator(prj_rec, training=True) - g_loss = generator_loss(fake_output, prj_filter, prj_rec, self.l1_ratio) + g_loss = generator_loss(fake_output, prj_filter, prj_rec, recon, self.l1_ratio) d_loss = discriminator_loss(real_output, fake_output) gradients_of_generator = gen_tape.gradient(g_loss, self.generator.trainable_variables) gradients_of_discriminator = disc_tape.gradient(d_loss, self.discriminator.trainable_variables) @@ -723,9 +810,9 @@ def recon(self): ########################################################################### # Reconstruction process monitor if self.recon_monitor: - plot_x, plot_loss = [], [] - recon_monitor = RECONmonitor("tomo") - recon_monitor.initial_plot(self.prj_input) + recon_monitor = RECONmonitor("tomo", self.prj_input) + else: + recon_monitor = None ########################################################################### for epoch in range(self.iter_num): @@ -740,440 +827,20 @@ def recon(self): # recon[epoch, :, :, :], prj_rec, gen_loss[epoch], d_loss = self.train_step_filter(prj, ang) ########################################################################### - plot_x.append(epoch) - plot_loss = gen_loss[: epoch + 1] + if recon_monitor is not None: + recon_monitor.update_plot(step_result) + if (epoch + 1) % 100 == 0: - # checkpoint.save(file_prefix=checkpoint_prefix) - if recon_monitor: - prj_rec = np.reshape(step_result["prj_rec"], (nang, px)) - prj_diff = np.abs(prj_rec - self.prj_input.reshape((nang, px))) - rec_plt = np.reshape(recon[epoch], (px, px)) - recon_monitor.update_plot(epoch, prj_diff, rec_plt, plot_x, plot_loss) print( "Iteration {}: G_loss is {} and D_loss is {}".format( epoch + 1, gen_loss[epoch], step_result["d_loss"].numpy() ) ) # plt.close() + if recon_monitor is not None: + recon_monitor.close_plot() if self.save_wpath != None: self.generator.save(self.save_wpath + "generator.h5") self.discriminator.save(self.save_wpath + "discriminator.h5") return recon[epoch] # return avg_results(recon, gen_loss) - - -class GANphase: - def __init__(self, i_input, energy, z, pv, **kwargs): - phase_args = config["GANphase"] - phase_args.update(**kwargs) - super(GANphase, self).__init__() - tf_configures() - self.i_input = i_input - self.shape_input = self.i_input.shape - self.energy = energy - self.z = z - self.pv = pv - self.iter_num = phase_args["iter_num"] - self.conv_num = phase_args["conv_num"] - self.conv_size = phase_args["conv_size"] - self.dropout = phase_args["dropout"] - self.l1_ratio = phase_args["l1_ratio"] - self.abs_ratio = phase_args["abs_ratio"] - self.g_learning_rate = phase_args["g_learning_rate"] - self.d_learning_rate = phase_args["d_learning_rate"] - self.phase_only = phase_args["phase_only"] - self.save_wpath = phase_args["save_wpath"] - self.init_wpath = phase_args["init_wpath"] - self.init_model = phase_args["init_model"] - self.recon_monitor = phase_args["recon_monitor"] - self._make_model() - - def _make_model(self): - self.generator = make_generator(self.shape_input, - self.conv_num, - self.conv_size, - self.dropout, 1) - self.discriminator = make_discriminator(self.shape_input) - self.generator_optimizer = tf.keras.optimizers.AdamW(self.g_learning_rate, - weight_decay=1e-4, - beta_2=0.99, - clipnorm=1.0) - self.discriminator_optimizer = tf.keras.optimizers.AdamW(self.d_learning_rate, - weight_decay=1e-4, - beta_2=0.99, - clipnorm=1.0) - self.generator.compile() - self.discriminator.compile() - - - @tf.function - def rec_step(self, i_input, ff): - with tf.GradientTape() as gen_tape, tf.GradientTape() as disc_tape: - recon = self.generator(i_input) - phase = tfnor_phase(recon[:, :, :, 0]) - phase = tf.reshape(phase, [self.img_h, self.img_w]) - absorption = (1 - tfnor_phase(recon[:, :, :, 1])) * self.abs_ratio - absorption = tf.reshape(absorption, [self.img_h, self.img_w]) - if self.phase_only: - absorption = tf.zeros_like(phase) - phase_obj = PhaseFresnel(phase, absorption, ff, self.img_w) - i_rec = phase_obj.compute() - real_output = self.discriminator(i_input, training=True) - fake_output = self.discriminator(i_rec, training=True) - g_loss = generator_loss(fake_output, i_input, i_rec, self.l1_ratio) - d_loss = discriminator_loss(real_output, fake_output) - gradients_of_generator = gen_tape.gradient(g_loss, self.generator.trainable_variables) - gradients_of_discriminator = disc_tape.gradient(d_loss, self.discriminator.trainable_variables) - self.generator_optimizer.apply_gradients(zip(gradients_of_generator, self.generator.trainable_variables)) - self.discriminator_optimizer.apply_gradients( - zip(gradients_of_discriminator, self.discriminator.trainable_variables) - ) - return {"phase": phase, - "absorption": absorption, - "i_rec": i_rec, - "g_loss": g_loss, - "d_loss": d_loss} - - def recon(self, i_input=None): - """ - Fast & stable reconstruction loop: - - On-device EMA & rollback snapshots (no host copies) - - Rare disk saves (weights-only) instead of per-step saves - - Minimal host sync (log every k steps) - - Brief D 'freeze' by zeroing LR (no retracing) - Expects: self.generator, self.discriminator, self.recon_step(prj, ang), - self.iter_num, self.angle, self.prj_input, self.shape_output. - Optional: self.g_optimizer, self.d_optimizer, self.recon_monitor, self.save_wpath, self.init_wpath. - """ - # ---------- Optional initial load ---------- - if getattr(self, "init_wpath", None): - try: - self.generator.load_weights(os.path.join(self.init_wpath, "generator.keras")) - self.discriminator.load_weights(os.path.join(self.init_wpath, "discriminator.keras")) - print("Models are initialized") - except Exception as e: - print(f"[init] load failed: {e}") - - # ---------- Inputs ---------- - if i_input is not None: - self.i_input = i_input - i_input = tf.cast(self.i_input, tf.float32)[None, ..., None] - - - # ---------- Tunables (you can override on self.*) ---------- - ema_decay = float(getattr(self, "ema_decay", 0.99)) - snapshot_every = int(getattr(self, "snapshot_every", 50)) # on-device RAM snapshot - disk_save_every = int(getattr(self, "disk_save_every", 100)) # rare weights-only to disk - log_every = int(getattr(self, "log_every", 10)) # host sync for logs/plots - monitor_every = int(getattr(self, "monitor_every", 100)) # plotting interval - spike_factor = float(getattr(self, "spike_factor", 1.5)) - warmup_steps = int(getattr(self, "warmup_steps", max(5, int(self.iter_num)//20))) - lr_backoff = float(getattr(self, "lr_backoff", 0.5)) - lr_floor = float(getattr(self, "lr_floor", 1e-6)) - freeze_disc_max = int(getattr(self, "freeze_disc_max", 10)) - weights_dir = getattr(self, "save_wpath", None) - - # ---------- EMA & Snapshots (on device) ---------- - ema = DeviceEMA(self.generator, decay=ema_decay) - snap = DeviceSnapshot([self.generator.trainable_variables, - self.discriminator.trainable_variables]) - snap.snapshot() # initial good state - - # Scalar EMAs for anomaly detection (no host sync) - g_ema = ScalarEMA(0.95) - d_ema = ScalarEMA(0.95) - - # Keep original learning rates to restore after temporary freezes - g_lr0 = (float(tf.keras.backend.get_value(self.g_optimizer.learning_rate)) - if hasattr(self, "g_optimizer") else None) - d_lr0 = (float(tf.keras.backend.get_value(self.d_optimizer.learning_rate)) - if hasattr(self, "d_optimizer") else None) - - # ---------- Monitor ---------- - pbar = tqdm(total=int(self.iter_num), desc="Reconstruction", leave=True) - - if self.recon_monitor: - recon_monitor = RECONmonitor("phase", self.i_input) - freeze_disc_steps = 0 - last_recon_np = None # only updated occasionally to avoid host sync - step_result = {} - - for step in range(int(self.iter_num)): - # Apply temporary D freeze by LR=0 (no retracing) - if hasattr(self, "d_optimizer") and d_lr0 is not None: - if freeze_disc_steps > 0: - set_lr(self.d_optimizer, 0.0) - elif float(tf.keras.backend.get_value(self.d_optimizer.learning_rate)) == 0.0: - set_lr(self.d_optimizer, d_lr0) - - # One step — your function should do forward/backward/updates (prefer @tf.function in it) - step_result = self.recon_step(i_input, ff) - if "recon" in step_result and (step % log_every == 0): - y = step_result["recon"] - if flat_output(y): - # 1) roll back to last good snapshot (you already have DeviceSnapshot.restore()) - snap.restore() - # 2) reduce GAN pressure and boost pixel losses - if hasattr(self, "lambda_gan"): self.lambda_gan *= 0.5 - # 3) small LR backoff for D; keep or raise G LR slightly - if hasattr(self, "d_optimizer"): - cur = float(tf.keras.backend.get_value(self.d_optimizer.learning_rate)) - set_lr(self.d_optimizer, max(1e-6, 0.5 * cur)) - - g_loss_t = tf.cast(step_result["g_loss"], tf.float32) - d_loss_t = tf.cast(step_result["d_loss"], tf.float32) - - # Guard: NaN/Inf - finite = tf.math.is_finite(g_loss_t) & tf.math.is_finite(d_loss_t) - if not bool(finite.numpy()): # scalar sync (cheap) - snap.restore() # instant on-device rollback - if hasattr(self, "g_optimizer") and g_lr0 is not None: - cur = float(tf.keras.backend.get_value(self.g_optimizer.learning_rate)) - set_lr(self.g_optimizer, max(lr_floor, cur * lr_backoff)) - if hasattr(self, "d_optimizer") and d_lr0 is not None: - cur = float(tf.keras.backend.get_value(self.d_optimizer.learning_rate)) - set_lr(self.d_optimizer, max(lr_floor, cur * lr_backoff)) - freeze_disc_steps = freeze_disc_max - if step % log_every == 0: - pbar.set_postfix_str("NaN/Inf→rollback") - pbar.update(1) - continue - - # Update scalar EMAs (device) - g_ema_v = g_ema.update(g_loss_t) - d_ema_v = d_ema.update(d_loss_t) - - # Spike detection (occasional host fetch for decision) - do_check = (step > warmup_steps) and (step % log_every == 0) - if do_check: - g_loss = float(g_loss_t.numpy()) - d_loss = float(d_loss_t.numpy()) - g_bar = float(g_ema_v.numpy()) - d_bar = float(d_ema_v.numpy()) - if g_loss > spike_factor * max(1e-8, g_bar) or d_loss > spike_factor * max(1e-8, d_bar): - snap.restore() - if hasattr(self, "g_optimizer") and g_lr0 is not None: - cur = float(tf.keras.backend.get_value(self.g_optimizer.learning_rate)) - set_lr(self.g_optimizer, max(lr_floor, cur * lr_backoff)) - if hasattr(self, "d_optimizer") and d_lr0 is not None: - cur = float(tf.keras.backend.get_value(self.d_optimizer.learning_rate)) - set_lr(self.d_optimizer, max(lr_floor, cur * lr_backoff)) - freeze_disc_steps = freeze_disc_max - pbar.set_postfix_str("spike→rollback") - pbar.update(1) - continue - - # Good step: update EMA weights and snapshot occasionally (device only) - ema.update() # on-device - if step % snapshot_every == 0: - snap.snapshot() # on-device, cheap - - # Rare disk save (weights-only) - if weights_dir and (step % disk_save_every == 0 or step == int(self.iter_num) - 1): - try: - os.makedirs(weights_dir, exist_ok=True) - self.generator.save_weights(os.path.join(weights_dir, "generator.weights.h5")) - self.discriminator.save_weights(os.path.join(weights_dir, "discriminator.weights.h5")) - except Exception as e: - if step % log_every == 0: - pbar.set_postfix_str(f"save err: {e}") - - # Progress/logging (host sync every log_every steps only) - if step % log_every == 0: - pbar.set_postfix(G_loss=f"{float(g_loss_t.numpy()):.4f}", - D_loss=f"{float(d_loss_t.numpy()):.4f}") - - if self.recon_monitor: - recon_monitor.update_plot(step_result) - # countdown - if freeze_disc_steps > 0: - freeze_disc_steps -= 1 - - pbar.update(1) - - pbar.close() - if getattr(self, "recon_monitor", False) and 'recon_monitor' in locals() and recon_monitor is not None: - try: recon_monitor.close_plot() - except Exception: pass - - # Final output: evaluate with EMA weights (swap in/out on device, no host copy) - ema.swap_in() - try: - # If your generator supports a direct forward to produce recon from (prj, ang), - # compute it here. Otherwise fall back to the last monitored recon. - final = last_recon_np - if final is None and "recon" in step_result: - final = step_result["recon"].numpy() - if final is None: - final = np.zeros(self.shape_output, dtype=np.float32) - finally: - ema.swap_out() - - return np.reshape(final.astype(np.float32), self.shape_output) - # @property - # def recon(self): - # ff = ffactor(self.img_w * 2, self.energy, self.z, self.pv) - # i_input = np.reshape(self.i_input, (1, self.img_h, self.img_w, 1)) - # i_input = tf.cast(i_input, dtype=tf.float32) - # self.make_model() - # # phase = np.zeros((self.iter_num, self.img_h, self.img_w)) - # # absorption = np.zeros((self.iter_num, self.img_h, self.img_w)) - # # gen_loss = np.zeros(self.iter_num) - - # ########################################################################### - # # Reconstruction process monitor - # pbar = tqdm(total=self.iter_num, desc="Reconstruction Progress", position=0, leave=True) - # if self.recon_monitor: - # recon_monitor = RECONmonitor("phase", self.i_input) - # # plot_x, plot_loss = [], [] - # # recon_monitor = RECONmonitor("phase") - # # recon_monitor.initial_plot(self.i_input) - # # pbar = tqdm(total=self.iter_num, desc="Reconstruction Progress", position=0, leave=True) - # ########################################################################### - # for epoch in range(self.iter_num): - - # ########################################################################### - # ## Call the rconstruction step - # step_result = self.rec_step(i_input, ff) - # pbar.set_postfix(G_loss=step_result["g_loss"].numpy(), D_loss=step_result["d_loss"].numpy()) - # pbar.update(1) - # # phase[epoch, :, :] = step_results["phase"] - # # absorption[epoch, :, :] = step_results["absorption"] - # # i_rec = step_results["i_rec"] - # # gen_loss[epoch] = step_results["g_loss"] - # # d_loss = step_results["d_loss"] - # ########################################################################### - # if self.recon_monitor: - # recon_monitor.update_plot(step_result) - # # plot_x.append(epoch) - # # plot_loss = gen_loss[: epoch + 1] - # # pbar.set_postfix(G_loss=gen_loss[epoch], D_loss=d_loss.numpy()) - # # pbar.update(1) - # # if (epoch + 1) % 100 == 0: - # # if recon_monitor: - # # i_rec = np.reshape(i_rec, (self.px, self.px)) - # # i_diff = np.abs(i_rec - self.i_input.reshape((self.px, self.px))) - # # phase_plt = np.reshape(phase[epoch], (self.px, self.px)) - # # recon_monitor.update_plot(epoch, i_diff, phase_plt, plot_x, plot_loss) - # if self.recon_monitor: - # recon_monitor.close_plot() - # absorption = np.reshape(step_result['absorption'].numpy().astype(np.float32), (self.img_w, self.img_w)) - # phase = np.reshape(step_result['phase'].numpy().astype(np.float32), (self.img_w, self.img_w)) - # return absorption, phase - - -class GANdiffraction: - def __init__(self, i_input, mask, **kwargs): - diffraction_args = config["GANdiffraction"] - diffraction_args.update(**kwargs) - super(GANdiffraction, self).__init__() - self.i_input = i_input - self.mask = mask - self.px, _ = i_input.shape - self.iter_num = diffraction_args["iter_num"] - self.conv_num = diffraction_args["conv_num"] - self.conv_size = diffraction_args["conv_size"] - self.dropout = diffraction_args["dropout"] - self.l1_ratio = diffraction_args["l1_ratio"] - self.abs_ratio = diffraction_args["abs_ratio"] - self.g_learning_rate = diffraction_args["g_learning_rate"] - self.d_learning_rate = diffraction_args["d_learning_rate"] - self.phase_only = diffraction_args["phase_only"] - self.save_wpath = diffraction_args["save_wpath"] - self.init_wpath = diffraction_args["init_wpath"] - self.init_model = diffraction_args["init_model"] - self.recon_monitor = diffraction_args["recon_monitor"] - self.filter = None - self.generator = None - self.discriminator = None - self.filter_optimizer = None - self.generator_optimizer = None - self.discriminator_optimizer = None - - @tf.function - def tfnor_diff(img): - img = (img - tf.reduce_min(img)) / (tf.reduce_max(img) - tf.reduce_min(img)) - return img - - def make_model(self): - self.generator = make_generator( - self.i_input.shape[0], self.i_input.shape[1], self.conv_num, self.conv_size, self.dropout, 2 - ) - self.discriminator = make_discriminator(self.i_input.shape[0], self.i_input.shape[1]) - self.filter_optimizer = tf.keras.optimizers.Adam(5e-3) - self.generator_optimizer = tf.keras.optimizers.Adam(self.g_learning_rate) - self.discriminator_optimizer = tf.keras.optimizers.Adam(self.d_learning_rate) - - @tf.function - def rec_step(self, i_input): - with tf.GradientTape() as gen_tape, tf.GradientTape() as disc_tape: - recon = self.generator(i_input) - phase = self.tfnor_diff(recon[:, :, :, 0]) - phase = tf.reshape(phase, [self.px // 2, self.px // 2]) - phase = tf.pad(phase, [[64, 64], [64, 64]]) - absorption = (1 - self.tfnor_diff(recon[:, :, :, 1])) * self.abs_ratio - absorption = tf.reshape(absorption, [self.px // 2, self.px // 2]) - absorption = tf.pad(absorption, [[64, 64], [64, 64]]) - if self.phase_only: - absorption = tf.zeros_like(phase) - phase_obj = PhaseFraunhofer(phase, absorption) - i_rec = phase_obj.compute() - mask = tf.reshape(self.mask, [1, self.mask.shape[0], self.mask.shape[1], 1]) - if self.mask: - i_rec = tf.multiply(i_rec, mask) - i_rec = self.tfnor_diff(i_rec) - real_output = self.discriminator(i_input, training=True) - fake_output = self.discriminator(i_rec, training=True) - g_loss = generator_loss(fake_output, i_input, i_rec, self.l1_ratio) - d_loss = discriminator_loss(real_output, fake_output) - gradients_of_generator = gen_tape.gradient(g_loss, self.generator.trainable_variables) - gradients_of_discriminator = disc_tape.gradient(d_loss, self.discriminator.trainable_variables) - self.generator_optimizer.apply_gradients(zip(gradients_of_generator, self.generator.trainable_variables)) - self.discriminator_optimizer.apply_gradients( - zip(gradients_of_discriminator, self.discriminator.trainable_variables) - ) - return {"phase": phase, "absorption": absorption, "i_rec": i_rec, "g_loss": g_loss, "d_loss": d_loss} - - @property - def recon(self): - i_input = np.reshape(self.i_input, (1, self.px, self.px, 1)) - i_input = tf.cast(i_input, dtype=tf.float32) - self.make_model() - phase = np.zeros((self.iter_num, self.px, self.px)) - absorption = np.zeros((self.iter_num, self.px, self.px)) - gen_loss = np.zeros(self.iter_num) - - ########################################################################### - # Reconstruction process monitor - if self.recon_monitor: - plot_x, plot_loss = [], [] - recon_monitor = RECONmonitor("phase") - recon_monitor.initial_plot(self.i_input) - pbar = tqdm(total=self.iter_num, desc="Reconstruction Progress", position=0, leave=True) - ########################################################################### - for epoch in range(self.iter_num): - - ########################################################################### - ## Call the rconstruction step - step_results = self.rec_step(i_input) - phase[epoch, :, :] = step_results["phase"] - absorption[epoch, :, :] = step_results["absorption"] - i_rec = step_results["i_rec"] - gen_loss[epoch] = step_results["g_loss"] - d_loss = step_results["d_loss"] - ########################################################################### - - if self.recon_monitor: - plot_x.append(epoch) - plot_loss = gen_loss[: epoch + 1] - pbar.set_postfix(G_loss=gen_loss[epoch], D_loss=d_loss.numpy()) - pbar.update(1) - if (epoch + 1) % 100 == 0: - if self.recon_monitor: - i_rec = np.reshape(i_rec, (self.px, self.px)) - i_diff = np.abs(i_rec - self.i_input.reshape((self.px, self.px))) - phase_plt = np.reshape(phase[epoch], (self.px, self.px)) - recon_monitor.update_plot(epoch, i_diff, phase_plt, plot_x, plot_loss) - if self.recon_monitor: - recon_monitor.close_plot() - return absorption[epoch], phase[epoch] diff --git a/ganrectf/loss.py b/ganrectf/loss.py new file mode 100644 index 0000000..c2e342b --- /dev/null +++ b/ganrectf/loss.py @@ -0,0 +1,411 @@ +import tensorflow as tf + +@tf.function +def discriminator_loss(real_output, fake_output, smoothing = 0.1): + real_loss = tf.reduce_mean( + tf.nn.sigmoid_cross_entropy_with_logits(logits=real_output, labels=tf.ones_like(real_output) * (1 - smoothing)) + ) + fake_loss = tf.reduce_mean( + tf.nn.sigmoid_cross_entropy_with_logits(logits=fake_output, labels=tf.zeros_like(fake_output)) + ) + total_loss = real_loss + fake_loss + return total_loss + +@tf.function +def generator_loss(fake_output, img_target, img_pred, recon, l1_ratio): + # Cast all inputs to float32 for consistency. + fake_output = tf.cast(fake_output, tf.float32) + img_target = tf.cast(img_target, tf.float32) + img_pred = tf.cast(img_pred, tf.float32) + + # Adversarial loss: compare fake_output with ones (targeting fooling the discriminator). + adv_loss = tf.reduce_mean( + tf.nn.sigmoid_cross_entropy_with_logits( + logits=fake_output, + labels=tf.ones_like(fake_output) + ) + ) + # Reconstruction loss: L1 loss between the ground truth image and the predicted image. + abs_loss = l1_loss(img_pred, img_target) + + # SSIM loss: 1 - ssim score. + ssim_component = ssim_loss(img_pred, img_target, max_val=tf.reduce_max(img_target)) + + # Total Variation loss: encourages smoothness in the prediction. + tv_component = total_variation_loss(recon) + + # Total generator loss: combine adversarial, reconstruction, SSIM, and TV losses. The weights can be adjusted as needed. + # The working version for most of the cases: + + gen_loss = adv_loss + \ + abs_loss*20 + \ + ssim_component*50 + 1e-5 * tv_component + \ + 0.05 * mean_match(img_target, img_pred) + \ + 0.05 * variance_floor(img_pred) + # Testing for 3d xrd + # gen_loss = adv_loss + \ + # abs_loss*0.01 + \ + # ssim_component*100 + 1e-5 * tv_component + \ + # 0.05 * mean_match(img_target, img_pred) + \ + # 0.05 * variance_floor(img_pred) + # gen_loss = adv_loss + ssim_component*1e2 + + # gen_loss = adv_loss + svmbir_loss(img_target, img_pred, recon) * l1_ratio + + return gen_loss + + +def l1_loss(img1, img2): + # Cast both images to float32 to ensure consistent types. + img1 = tf.cast(img1, tf.float32) + img2 = tf.cast(img2, tf.float32) + return tf.reduce_mean(tf.abs(img1 - img2)) + +def l2_loss(img1, img2): + return tf.square(tf.reduce_mean(tf.abs(img1 - img2))) + + +def _gaussian_kernel_3d(size: int, sigma: float) -> tf.Tensor: + """ + Builds a 3D Gaussian kernel of shape [size, size, size, 1, 1]. + """ + ax = tf.range(-size//2 + 1, size//2 + 1, dtype=tf.float32) + xx, yy, zz = tf.meshgrid(ax, ax, ax, indexing='ij') + kernel = tf.exp(-(xx**2 + yy**2 + zz**2) / (2.0 * sigma**2)) + kernel = kernel / tf.reduce_sum(kernel) + return kernel[:, :, :, tf.newaxis, tf.newaxis] + + +def _volumetric_ssim_3d( + x: tf.Tensor, + y: tf.Tensor, + max_val: float, + filter_size: int, + filter_sigma: float, + k1: float, + k2: float, + eps: float +) -> tf.Tensor: + """ + Computes true 3D SSIM over volumes using a 3D Gaussian window. + """ + c1 = (k1 * max_val) ** 2 + c2 = (k2 * max_val) ** 2 + + kernel = _gaussian_kernel_3d(filter_size, filter_sigma) + strides = [1, 1, 1, 1, 1] + padding = 'SAME' + + mu_x = tf.nn.conv3d(x, kernel, strides, padding) + mu_y = tf.nn.conv3d(y, kernel, strides, padding) + + mu_x2 = mu_x * mu_x + mu_y2 = mu_y * mu_y + mu_xy = mu_x * mu_y + + sigma_x2 = tf.nn.conv3d(x * x, kernel, strides, padding) - mu_x2 + sigma_y2 = tf.nn.conv3d(y * y, kernel, strides, padding) - mu_y2 + sigma_xy = tf.nn.conv3d(x * y, kernel, strides, padding) - mu_xy + + num = (2.0 * mu_xy + c1) * (2.0 * sigma_xy + c2) + den = (mu_x2 + mu_y2 + c1) * (sigma_x2 + sigma_y2 + c2 + eps) + ssim_map = num / den + return tf.reduce_mean(ssim_map) + + + +def variance_floor(y, tau=0.02): # tau ~ 1–3% of dynamic range in [-1,1] + v = tf.math.reduce_variance(y, axis=[1,2,3]) + # penalize only if variance falls under tau^2 + return tf.reduce_mean(tf.nn.relu(tau*tau - v)) + +def mean_match(y_true, y_pred): + m_t = tf.reduce_mean(y_true, axis=[1,2,3]) + m_p = tf.reduce_mean(y_pred, axis=[1,2,3]) + return tf.reduce_mean(tf.square(m_t - m_p)) + +def ssim_loss( + x: tf.Tensor, + y: tf.Tensor, + max_val: float = 1.0, + filter_size: int = 11, + filter_sigma: float = 1.5, + k1: float = 0.01, + k2: float = 0.03, + eps: float = 1e-8, + volumetric: bool = False +) -> tf.Tensor: + """ + Computes SSIM loss (1 - mean SSIM) for 2D images or 3D volumes. + + For 3D inputs, uses 2D slice-wise SSIM by default (with tf.image.ssim). + Set volumetric=True to use true volumetric SSIM via 3D conv. + + Args: + x: 4D ([B,H,W,C]) or 5D ([B,D,H,W,C]) Tensor. + y: Ground-truth tensor of same shape as x. + max_val: Dynamic range of input values. + filter_size: Gaussian window size. + filter_sigma: Gaussian window sigma. + k1, k2: SSIM constants. + eps: Small constant to stabilize division. + volumetric: If True and input is 5D, use true 3D SSIM. + + Returns: + Scalar Tensor: SSIM loss = 1 - mean SSIM. + """ + ndim = x.shape.ndims + if ndim == 4: + # 2D SSIM via TensorFlow builtin + ssim_map = tf.image.ssim( + x, y, max_val, + filter_size=filter_size, + filter_sigma=filter_sigma, + k1=k1, k2=k2 + ) + return 1.0 - tf.reduce_mean(ssim_map) + elif ndim == 5: + if volumetric: + # True volumetric SSIM + return 1.0 - _volumetric_ssim_3d( + x, y, max_val, filter_size, filter_sigma, k1, k2, eps + ) + else: + # Slice-wise 2D SSIM over depth + shape = tf.shape(x) + batch, depth = shape[0], shape[1] + # reshape to [batch*depth, H, W, C] + x_slices = tf.reshape(x, [batch * depth, x.shape[2], x.shape[3], x.shape[4]]) + y_slices = tf.reshape(y, [batch * depth, y.shape[2], y.shape[3], y.shape[4]]) + ssim_map = tf.image.ssim( + x_slices, y_slices, max_val, + filter_size=filter_size, + filter_sigma=filter_sigma, + k1=k1, k2=k2 + ) + return 1.0 - tf.reduce_mean(ssim_map) + else: + raise ValueError(f"Input must be 4D or 5D, got {ndim}D.") + + +def total_variation_2d(x: tf.Tensor) -> tf.Tensor: + """ + Computes total variation for 2D images. + + Args: + x: 4D Tensor of shape [batch, height, width, channels]. + + Returns: + Scalar Tensor representing the total variation. + """ + + dh = x[:, 1:, :, :] - x[:, :-1, :, :] + dw = x[:, :, 1:, :] - x[:, :, :-1, :] + return tf.reduce_sum(tf.abs(dh)) + tf.reduce_sum(tf.abs(dw)) + + +def total_variation_3d(x: tf.Tensor) -> tf.Tensor: + """ + Computes total variation for 3D volumes. + + Args: + x: 5D Tensor of shape [batch, depth, height, width, channels]. + + Returns: + Scalar Tensor representing the total variation. + """ + dd = x[:, 1:, :, :, :] - x[:, :-1, :, :, :] + dh = x[:, :, 1:, :, :] - x[:, :, :-1, :, :] + dw = x[:, :, :, 1:, :] - x[:, :, :, :-1, :] + return ( + tf.reduce_sum(tf.abs(dd)) + + tf.reduce_sum(tf.abs(dh)) + + tf.reduce_sum(tf.abs(dw)) + ) + + +def total_variation_loss( + x: tf.Tensor, + reduction: str = 'sum' +) -> tf.Tensor: + """ + Computes total variation loss for both 2D and 3D inputs. + + Args: + x: 4D or 5D Tensor. Shape: + - 4D: [batch, height, width, channels] + - 5D: [batch, depth, height, width, channels] + reduction: 'sum' or 'mean'. If 'mean', divides by the number of elements. + + Returns: + Scalar Tensor: total variation loss. + """ + ndim = x.shape.ndims + if ndim == 4: + tv = total_variation_2d(x) + elif ndim == 5: + tv = total_variation_3d(x) + else: + raise ValueError(f"Input tensor must be 4D or 5D, got {ndim}D.") + + if reduction == 'mean': + return tv / tf.cast(tf.size(x), x.dtype) + return tv + +# ----------------------------- +# Forward Model Loss: ||input_proj - pred_proj||^2_W +# ----------------------------- + +def forward_model_loss(pred_proj, input_proj, weight_type="transmission", sigma_y=1.0, eps=1e-8): + """ + Computes the forward model loss. + Args: + pred_proj: Predicted projection tensor with shape (batch, n_angles, n_detectors). + input_proj: Measured projection tensor with shape (batch, n_angles, n_detectors). + weight_type: One of "unweighted", "transmission", "transmission_root", or "emission". + sigma_y: Parameter controlling the assumed noise standard deviation. + eps: Small constant for numerical stability. + Returns: + A scalar Tensor representing the forward model loss. + """ + # Ensure inputs are float32. + pred_proj = tf.cast(pred_proj, tf.float32) + input_proj = tf.cast(input_proj, tf.float32) + + # Compute weighting Lambda based on weight_type. + if weight_type == "unweighted": + Lambda = tf.ones_like(input_proj) + elif weight_type == "transmission": + Lambda = tf.exp(-input_proj) + elif weight_type == "transmission_root": + Lambda = tf.exp(-input_proj / 2.0) + elif weight_type == "emission": + Lambda = 1.0 / (input_proj + 0.1) + else: + raise ValueError(f"Unknown weight_type: {weight_type}") + + diff = input_proj - pred_proj + weighted_sq_error = Lambda * tf.square(diff) + return tf.reduce_mean(weighted_sq_error) / (2 * sigma_y**2 + eps) + + +def qggmrf_3d_loss(pred_volume, sigma_x, p, q, T, + neighborhood_weight=1.0, eps=1e-8): + """ + Computes the qGGMRF prior loss for a 3D volume, with extra safeguards + to prevent NaNs by ensuring no division-by-zero or zero^negative_power. + + Args: + pred_volume: 3D volume tensor with shape (batch, D, H, W, 1). + sigma_x: Primary regularization parameter (scalar or tensor). + p: Shape parameter controlling the potential function. + q: Shape parameter controlling the potential function. + T: Threshold parameter (scalar or tensor). + neighborhood_weight: Scalar to weight the overall prior term. + eps: Small constant for numerical stability. + + Returns: + A scalar Tensor representing the qGGMRF prior loss. + """ + pred = tf.cast(pred_volume, tf.float32) + + # Compute finite differences + dz = pred[:, 1:, :, :, :] - pred[:, :-1, :, :, :] + dy = pred[:, :, 1:, :, :] - pred[:, :, :-1, :, :] + dx = pred[:, :, :, 1:, :] - pred[:, :, :, :-1, :] + + # Flatten and concatenate + batch = tf.shape(pred)[0] + diffs = tf.concat([ + tf.reshape(dz, [batch, -1]), + tf.reshape(dy, [batch, -1]), + tf.reshape(dx, [batch, -1]) + ], axis=1) + + abs_diffs = tf.abs(diffs) + + # safe denominators + safe_sigma = sigma_x + eps + safe_T = T + eps + + # scaled differences (clipped to [eps, ∞) to avoid zero^negative_power) + scaled = tf.clip_by_value(abs_diffs / safe_sigma, eps, tf.float32.max) + scaledT = tf.clip_by_value(abs_diffs / safe_T, eps, tf.float32.max) + + # numerator and denominator with eps offsets + num = tf.pow(scaled + eps, q) + den = 1.0 + tf.pow(scaledT + eps, q - p) + + # final loss terms, with divide_no_nan and eps in denominator + loss_terms = tf.math.divide_no_nan(num, + den + eps) + + # replace any remaining NaNs (just in case) with zeros + loss_terms = tf.where( + tf.math.is_nan(loss_terms), + tf.zeros_like(loss_terms), + loss_terms + ) + + return neighborhood_weight * tf.reduce_mean(loss_terms) + + +def proximal_map_prior_loss(pred_volume, prox_image, sigma_p, eps=1e-8): + """ + Computes the proximal map prior loss. + Args: + pred_volume: Predicted 3D volume tensor with shape (batch, D, H, W, 1). + prox_image: Denoised/processed image from an external prior. + sigma_p: Parameter controlling the assumed noise level in the prior. + eps: Small constant for numerical stability. + Returns: + A scalar Tensor representing the proximal map prior loss. + """ + pred_volume = tf.cast(pred_volume, tf.float32) + prox_image = tf.cast(prox_image, tf.float32) + diff = pred_volume - prox_image + return tf.reduce_mean(tf.square(diff)) / (2 * sigma_p**2 + eps) + + +def svmbir_loss(input_proj, pred_proj, pred_volume, + sigma_y=1.0, + weight_type="transmission", + use_qggmrf=True, + sigma_x=0.02, p=1.2, q=2.0, T=1.0, + neighborhood_weight=1.0, + use_prox=False, + prox_image=None, + sigma_p=0.01): + """ + Computes the combined SVMBIR loss consisting of: + - A forward model data fidelity term. + - A prior model term (either qGGMRF or proximal map prior). + Args: + input_proj: Measured projection tensor (batch, n_angles, n_detectors). + pred_proj: Predicted projection tensor (batch, n_angles, n_detectors). + pred_volume: Predicted 3D volume tensor (batch, D, H, W, 1). + sigma_y: Noise standard deviation for the forward model. + weight_type: Weighting type for the projection. + use_qggmrf: If True, use the qGGMRF prior; otherwise, use the proximal map prior. + sigma_x, p, q, T: Hyperparameters for the qGGMRF prior. + neighborhood_weight: Weighting for the prior term. + use_prox: If True, use the proximal map prior. + prox_image: Tensor for the proximal map prior (required if use_prox is True). + sigma_p: Noise parameter for the proximal map prior. + Returns: + A scalar Tensor representing the total loss. + """ + data_loss = forward_model_loss(pred_proj, input_proj, weight_type, sigma_y) + + if use_prox and prox_image is not None: + prior_loss = proximal_map_prior_loss(pred_volume, prox_image, sigma_p) + else: + prior_loss = qggmrf_3d_loss(pred_volume, sigma_x, p, q, T, neighborhood_weight) + + total_loss = data_loss + prior_loss*1e-5 + # total_loss = data_loss + + # Optionally, add debugging checks: + tf.debugging.check_numerics(total_loss, message="Total loss has NaNs") + + return total_loss \ No newline at end of file diff --git a/ganrectf/propagators.py b/ganrectf/propagators.py index 6e8c0db..f3a65ff 100644 --- a/ganrectf/propagators.py +++ b/ganrectf/propagators.py @@ -97,7 +97,6 @@ def compute(self): ifp = tf.abs(tf.signal.ifft2d(self.ff * tf.signal.fft2d(abfs))) ** 2 ifp = tf.reshape(ifp, [ifp.shape[0], ifp.shape[1], 1]) ifp = tf.image.central_crop(ifp, 0.5) - ifp = tf.image.per_image_standardization(ifp) ifp = tf.reshape(ifp, [1, ifp.shape[0], ifp.shape[1], 1]) return ifp @@ -115,6 +114,4 @@ def compute(self): ifp = tf.math.log(ifp + self.shift_factor) ifp = tf.signal.fftshift(ifp) ifp = tf.reshape(ifp, [1, ifp.shape[0], ifp.shape[1], 1]) - ifp = tf.image.per_image_standardization(ifp) - # ifp = self.tfnor_diff(ifp) return ifp diff --git a/ganrectf/tfutils.py b/ganrectf/tfutils.py index d8b0c18..e5a7223 100644 --- a/ganrectf/tfutils.py +++ b/ganrectf/tfutils.py @@ -3,6 +3,197 @@ import numpy as np import tensorflow as tf + +@tf.function +def normalize_to_target_range( + generated: tf.Tensor, + target: tf.Tensor, + axis=None, + epsilon: float = 1e-8 +) -> tf.Tensor: + """ + Linearly rescales `generated` so that its [min, max] equals + the [min, max] of `target`, even if those values are negative. + If `generated` is constant (min == max), it will be mapped + to the midpoint of the `target` range. + + Args: + generated: tf.Tensor of any shape. + target: tf.Tensor broadcastable to generated. + axis: Dimensions to reduce over when computing min/max + (e.g. [1,2,3] to keep batch dim). None = all elements. + epsilon: Small constant to avoid divide-by-zero. + + Returns: + A tf.Tensor same shape as `generated`, but with + min→min(target) and max→max(target). + """ + # 1) compute per-sample minima and maxima + gen_min = tf.reduce_min(generated, axis=axis, keepdims=True) + gen_max = tf.reduce_max(generated, axis=axis, keepdims=True) + tar_min = tf.reduce_min(target, axis=axis, keepdims=True) + tar_max = tf.reduce_max(target, axis=axis, keepdims=True) + + # 2) compute ranges + gen_range = gen_max - gen_min + tar_range = tar_max - tar_min + + # 3) avoid zero division: if gen_range is too small, replace it with 1 + safe_gen_range = tf.where( + gen_range < epsilon, + tf.ones_like(gen_range), + gen_range + ) + + # 4) normalize to [0,1], then scale to [tar_min, tar_max] + normalized = (generated - gen_min) / safe_gen_range + scaled = normalized * tar_range + tar_min + + # 5) for constant inputs, map to target midpoint + target_mid = (tar_min + tar_max) * 0.5 + scaled = tf.where(gen_range < epsilon, target_mid, scaled) + return scaled + +@tf.function +def tfnor_tomo(img: tf.Tensor, eps: float = 1e-8) -> tf.Tensor: + """ + Standardizes input (zero-mean, unit-variance) and shifts to non-negative range. + Supports both 2D images and 3D volumes. + + Args: + img: 4D Tensor [B, H, W, C] for images or + 5D Tensor [B, D, H, W, C] for volumes. + eps: Small constant to avoid divide-by-zero. + + Returns: + Tensor of same shape as img, with per-sample standardized values shifted + so that the minimum is 0. + """ + # Determine reduction axes based on tensor rank + ndim = img.shape.ndims + if ndim == 4: + # [batch, height, width, channels] + axes = [1, 2, 3] + elif ndim == 5: + # [batch, depth, height, width, channels] + axes = [1, 2, 3, 4] + else: + # fallback: normalize over all elements + axes = None + + # Compute per-sample mean and std + mean = tf.reduce_mean(img, axis=axes, keepdims=True) + std = tf.math.reduce_std(img, axis=axes, keepdims=True) + # Standardize + img_norm = (img - mean) / (std + eps) + + # Shift so minimum becomes 0 + min_val = tf.reduce_min(img_norm, axis=axes, keepdims=True) + img_pos = (img_norm - min_val)/ (tf.reduce_max(img_norm - min_val) + eps) + + return img_pos + + +def tfnor_phase(img): + img = tf.image.per_image_standardization(img) + img = img / tf.reduce_max(img) + return img + + +def flat_output(y, thr=1e-4): + # y in [-1,1]; thr is very small variance + v = tf.math.reduce_variance(y) + return bool((v < thr).numpy()) + +def set_lr(opt, new_lr: float): + try: + opt.learning_rate.assign(new_lr) + except Exception: + opt.learning_rate = new_lr + +def _batch_assign(dst_list, src_list): + """Batch-assign src -> dst for a list of variable pairs (pure Python loop). + Batching into a single compiled function avoids per-variable kernel launches.""" + for d, s in zip(dst_list, src_list): + d.assign(s) + + +class DeviceEMA: + """EMA on device: shadow Variables match model.trainable_variables.""" + def __init__(self, model, decay=0.999): + self.model = model + self.decay = tf.constant(decay, tf.float32) + self.one_m = tf.constant(1.0 - decay, tf.float32) + self.src = list(model.trainable_variables) + self.shadow = [tf.Variable(v, trainable=False) for v in self.src] + self.backup = [tf.Variable(v, trainable=False) for v in self.src] + + @tf.function(reduce_retracing=True) + def update(self): + for s, v in zip(self.shadow, self.src): + s.assign_add(self.one_m * (v - s)) # numerically identical, one less multiply + + @tf.function(reduce_retracing=True) + def swap_in(self): + for b, v in zip(self.backup, self.src): + b.assign(v) + for v, s in zip(self.src, self.shadow): + v.assign(s) + + @tf.function(reduce_retracing=True) + def swap_out(self): + for v, b in zip(self.src, self.backup): + v.assign(b) + + +class DeviceSnapshot: + """On-device snapshot/restore of variables (no host copies). + + Compiled snapshot/restore to batch all assigns into a single device launch. + """ + def __init__(self, var_lists): + self.targets = [list(vs) for vs in var_lists] + self.copies = [[tf.Variable(v, trainable=False) for v in vs] + for vs in self.targets] + # Pre-compile snapshot/restore as tf.functions for this specific set of variables + self._do_snapshot = tf.function(self._raw_snapshot, reduce_retracing=True) + self._do_restore = tf.function(self._raw_restore, reduce_retracing=True) + + def _raw_snapshot(self): + for copies, targets in zip(self.copies, self.targets): + for c, v in zip(copies, targets): + c.assign(v) + + def _raw_restore(self): + for copies, targets in zip(self.copies, self.targets): + for v, c in zip(targets, copies): + v.assign(c) + + def snapshot(self): + self._do_snapshot() + + def restore(self): + self._do_restore() + + +class ScalarEMA: + """Scalar EMA without host sync.""" + def __init__(self, decay=0.95): + self.decay = tf.constant(decay, tf.float32) + self.value = tf.Variable(0.0, trainable=False, dtype=tf.float32) + self.has = tf.Variable(False, trainable=False, dtype=tf.bool) + + @tf.function(reduce_retracing=True) + def update(self, x): + x = tf.cast(x, tf.float32) + def _first(): + self.value.assign(x); self.has.assign(True) + return self.value + def _upd(): + self.value.assign(self.decay * self.value + (1.0 - self.decay) * x) + return self.value + return tf.cond(self.has, _upd, _first) + major, minor, _ = tf.__version__.split(".") if int(major) > 2 or (int(major) == 2 and int(minor) > 9): from tensorflow.keras import KerasTensor diff --git a/ganrectf/utils.py b/ganrectf/utils.py index 981ef1a..bd9ff76 100644 --- a/ganrectf/utils.py +++ b/ganrectf/utils.py @@ -1,10 +1,9 @@ import os +import datetime import numpy as np from numpy.fft import fftfreq import tifffile -import matplotlib.pyplot as plt -from skimage.metrics import structural_similarity as ssim -from IPython.display import display, clear_output +import tensorflow as tf def nor_tomo(img): @@ -92,132 +91,398 @@ def in_notebook(): class RECONmonitor: - def __init__(self, recon_target): - self.fig, self.axs = plt.subplots(2, 2, figsize=(16, 8)) - self.recon_target = recon_target - if self.recon_target == "tomo": - self.plot_txt = "Sinogram" - elif self.recon_target == "phase": - self.plot_txt = "Intensity" - - def initial_plot(self, img_input): - _, px = img_input.shape - self.im0 = self.axs[0, 0].imshow(img_input, cmap="gray") - self.axs[0, 0].set_title(self.plot_txt) - self.fig.colorbar(self.im0, ax=self.axs[0, 0]) - self.axs[0, 0].set_aspect("equal", "box") - self.im1 = self.axs[1, 0].imshow(img_input, cmap="jet") - self.tx1 = self.axs[1, 0].set_title("Difference of " + self.plot_txt + " for iteration 0") - self.fig.colorbar(self.im1, ax=self.axs[1, 0]) - self.axs[1, 0].set_aspect("equal") - self.im2 = self.axs[0, 1].imshow(np.zeros((px, px)), cmap="gray") - self.fig.colorbar(self.im2, ax=self.axs[0, 1]) - self.axs[0, 1].set_title("Reconstruction") - (self.im3,) = self.axs[1, 1].plot([], [], "r-") - self.axs[1, 1].set_title("Generator loss") - plt.tight_layout() - - def update_plot(self, epoch, img_diff, img_rec, plot_x, plot_loss): - self.tx1.set_text("Difference of " + self.plot_txt + " for iteration {0}".format(epoch)) - vmax = np.max(img_diff) - vmin = np.min(img_diff) - self.im1.set_data(img_diff) - self.im1.set_clim(vmin, vmax) - self.im2.set_data(img_rec) - vmax = np.max(img_rec) - vmin = np.min(img_rec) - self.im2.set_clim(vmin, vmax) - self.axs[1, 1].plot(plot_x, plot_loss, "r-") - plt.tight_layout() - if in_notebook(): - clear_output(wait=True) - display(self.fig) - plt.pause(0.001) - else: - plt.ion() # Turn on interactive mode - plt.draw() - plt.pause(0.001) + """TensorFlow-native reconstruction monitor. + + Logs scalar losses, gradient norms, SSIM scores, and image summaries + to a TensorBoard logdir. When running inside a Jupyter notebook, also + renders inline image panels using ``tf.io.encode_png`` and + ``IPython.display`` (no matplotlib dependency). + + Parameters + ---------- + recon_target : str + ``"tomo"``, ``"phase"``, or ``"tensor"``. + img_input : ndarray (2-D) + The measured input image (sinogram / intensity / ...). + logdir : str or None + TensorBoard log directory. Defaults to ``logs/ganrec/``. + update_rate : int + Write image / inline display every *update_rate* steps. + Scalar summaries are written every step. + """ - def close_plot(self): - plt.close() - -class RECONmonitor: - def __init__(self, recon_target, img_input): - self.fig, self.axs = plt.subplots(2, 2, figsize=(16, 8)) + def __init__(self, recon_target, img_input, logdir=None, update_rate=100): self.recon_target = recon_target - self.update_rate = 100 - self.img_input = img_input + self.img_input = img_input.astype(np.float32) self.img_h, self.img_w = img_input.shape - self.epoch = 0 - self.plot_y1, self.plot_y2 = [], [] - if self.recon_target in ["tomo", "tensor"]: - self.plot_txt = "Sinogram" - elif self.recon_target == "phase": - self.plot_txt = "Intensity" - self.__initial_plot() - - def __initial_plot(self): - self.im0 = self.axs[0, 0].imshow(self.img_input, cmap="gray") - self.axs[0, 0].set_title(self.plot_txt) - self.fig.colorbar(self.im0, ax=self.axs[0, 0]) - self.axs[0, 0].set_aspect("equal", "box") - self.im1 = self.axs[1, 0].imshow(self.img_input, cmap="jet") - self.tx1 = self.axs[1, 0].set_title("SSIM map of " + self.plot_txt + " for iteration 0") - self.fig.colorbar(self.im1, ax=self.axs[1, 0]) - self.axs[1, 0].set_aspect("equal") - self.im2 = self.axs[0, 1].imshow(np.zeros((self.img_w, self.img_w)), cmap="gray") - self.fig.colorbar(self.im2, ax=self.axs[0, 1]) - self.axs[0, 1].set_title("Reconstruction") - (self.im3,) = self.axs[1, 1].plot([], [], "r-") - self.axs[1, 1].set_title("Reconstruction loss") - self.axs[1, 1].set_yscale("log") - plt.tight_layout() - - def update_plot(self, step_result): - self.epoch = self.epoch+1 - self.plot_x = np.arange(self.epoch) - self.plot_y1.append(step_result['g_loss'].numpy()) - self.plot_y2.append(step_result['d_loss'].numpy()) - - if (self.epoch + 1) % self.update_rate == 0: - if self.recon_target == "tomo": - img_rec = np.reshape(step_result['recon'], (self.img_w, self.img_w)) - prj_rec = np.reshape(step_result['prj_rec'], (self.img_h, self.img_w)) - elif self.recon_target == "tensor": - img_rec = np.reshape(step_result['recon'][:,:,:,0], (self.img_w, self.img_w)) - prj_rec = np.reshape(step_result['prj_rec'], (self.img_h, self.img_w)) - elif self.recon_target == "phase": - img_rec = np.reshape(step_result['phase'], (self.img_w, self.img_w)) - prj_rec = np.reshape(step_result['i_rec'], (self.img_h, self.img_w)) - - - # img_diff = np.abs(prj_rec - self.img_input) - img_range = self.img_input.max() - self.img_input.min() - ssim_index, ssim_map = ssim(prj_rec, self.img_input, full=True, data_range=img_range) - self.tx1.set_text("SSIM map of " + self.plot_txt + " for iteration {0}".format(self.epoch)) - vmax = np.max(ssim_map) - vmin = np.min(ssim_map) - self.im1.set_data(ssim_map) - self.im1.set_clim(vmin, vmax) - self.im2.set_data(img_rec) - vmax = np.max(img_rec) - vmin = np.min(img_rec) - self.im2.set_clim(vmin, vmax) - self.axs[1, 1].plot(self.plot_x, self.plot_y1, "r-") - self.axs[1, 1].plot(self.plot_x, self.plot_y2, "b-") - - plt.tight_layout() - if in_notebook(): - clear_output(wait=True) - display(self.fig) - plt.pause(0.001) + self.update_rate = int(update_rate) + self.step = 0 + self.total_steps = 0 # set by caller for progress display + self._in_notebook = in_notebook() + + # Accumulated loss history for inline display + self._g_losses = [] + self._d_losses = [] + + if logdir is None: + ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + logdir = os.path.join("logs", "ganrec", ts) + self.logdir = logdir + self.writer = tf.summary.create_file_writer(logdir) + + # Map recon_target to the dict keys produced by forward_fn + if recon_target in ("tomo", "tensor"): + self._recon_key = "recon" + self._pred_key = "prj_rec" + elif recon_target == "phase": + self._recon_key = "phase" + self._pred_key = "i_rec" + else: + self._recon_key = "recon" + self._pred_key = "predicted" + + # Also accept "predicted" as a fallback for _pred_key + self._pred_fallback = "predicted" + + # Write the input image once + with self.writer.as_default(): + inp_img = self._to_image_tensor( + tf.constant(self.img_input)) + tf.summary.image("input", inp_img, step=0) + self.writer.flush() + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + @staticmethod + def _to_image_tensor(t): + """Normalise a 2-D+ tensor to [0, 1] and reshape to [1, H, W, 1].""" + t = tf.cast(t, tf.float32) + t_flat = tf.reshape(t, [-1]) + t_min = tf.reduce_min(t_flat) + t_max = tf.reduce_max(t_flat) + t_norm = (t - t_min) / (t_max - t_min + 1e-8) + if t_norm.shape.ndims is None or t_norm.shape.ndims == 2: + return tf.reshape(t_norm, [1, tf.shape(t_norm)[0], tf.shape(t_norm)[1], 1]) + elif t_norm.shape.ndims == 3: + return tf.reshape(t_norm, [1, tf.shape(t_norm)[0], tf.shape(t_norm)[1], tf.shape(t_norm)[2]]) + elif t_norm.shape.ndims == 4: + return t_norm[:1] + return tf.reshape(t_norm, [1, tf.shape(t_norm)[0], tf.shape(t_norm)[1], 1]) + + @staticmethod + def _to_png_bytes(img_4d): + """Convert a [1, H, W, 1] float32 tensor to PNG bytes.""" + img_uint8 = tf.cast(tf.clip_by_value(img_4d[0] * 255.0, 0, 255), tf.uint8) + return tf.io.encode_png(img_uint8) + + @tf.function(reduce_retracing=True) + def _tf_ssim(self, pred, target): + """Compute SSIM between two 2-D tensors (on device).""" + pred_4d = tf.reshape(pred, [1, tf.shape(pred)[0], tf.shape(pred)[1], 1]) + tgt_4d = tf.reshape(target, [1, tf.shape(target)[0], tf.shape(target)[1], 1]) + max_val = tf.reduce_max(tgt_4d) - tf.reduce_min(tgt_4d) + return tf.image.ssim(pred_4d, tgt_4d, max_val=max_val + 1e-8) + + def _get_pred_tensor(self, step_result): + """Resolve the predicted-measurement tensor from step_result.""" + if self._pred_key in step_result: + return step_result[self._pred_key] + return step_result.get(self._pred_fallback) + + # ------------------------------------------------------------------ # + # Inline Jupyter display (no matplotlib) + # ------------------------------------------------------------------ # + @staticmethod + def _colorbar_html(tensor, height=256): + """Generate a vertical HTML/CSS colorbar with min/max value labels. + + Uses a grayscale gradient matching the image normalization + (white = max, black = min) and annotates with true data values. + """ + if hasattr(tensor, 'numpy'): + t_np = tensor.numpy() + else: + t_np = np.asarray(tensor) + vmin, vmax = float(t_np.min()), float(t_np.max()) + return ( + f'
' + f'
{vmax:.3g}
' + f'
' + f'
{vmin:.3g}
' + f'
' + ) + + def _image_panel(self, title, tensor, b64_png, img_width=256): + """Build an HTML snippet for one image panel with a colorbar.""" + # Compute displayed image height from tensor aspect ratio + if hasattr(tensor, 'shape'): + shape = tensor.shape + # Handle [B,H,W,C], [H,W,C], [H,W] tensors + if shape.ndims == 4: + th, tw = int(shape[1]), int(shape[2]) + elif shape.ndims == 3: + th, tw = int(shape[0]), int(shape[1]) + elif shape.ndims == 2: + th, tw = int(shape[0]), int(shape[1]) else: - plt.ion() # Turn on interactive mode - plt.draw() - plt.pause(0.001) + th, tw = 1, 1 + img_height = int(img_width * th / max(tw, 1)) + else: + img_height = img_width + cbar = self._colorbar_html(tensor, height=img_height) + return ( + f'
' + f'
' + f'
{title}
' + f'' + f'
' + f'{cbar}' + f'
' + ) + + def _display_inline(self, step_result, ssim_val): + """Render an inline HTML panel in the notebook using IPython.display. + + Layout: + Row 1: Reconstruction (centered) + Row 2: Input + Predicted (side by side) + Row 3: Loss convergence curve + """ + from IPython.display import display, clear_output, HTML + import base64 + + parts = [] + + # Progress bar + if self.total_steps > 0: + pct = min(100, self.step * 100 / self.total_steps) + parts.append( + f'
' + f'
' + f'
' + f'Step {self.step}/{self.total_steps} ({pct:.0f}%)
' + ) + + parts.append( + f"G_loss: {self._g_losses[-1]:.4f}   " + f"D_loss: {self._d_losses[-1]:.4f}   " + f"SSIM: {ssim_val:.4f}" + ) + + # Row 1: Reconstruction + if self._recon_key in step_result: + recon_t = step_result[self._recon_key] + if self.recon_target == "tensor": + recon_t = recon_t[:, :, :, 0] if recon_t.shape.ndims == 4 else recon_t + rec_img = self._to_image_tensor(recon_t) + rec_png = self._to_png_bytes(rec_img).numpy() + rec_b64 = base64.b64encode(rec_png).decode() + parts.append( + '
' + ) + parts.append(self._image_panel("Reconstruction", recon_t, rec_b64)) + parts.append("
") + + # Row 2: Input + Predicted + parts.append( + '
' + ) + inp_t = tf.constant(self.img_input) + inp_img = self._to_image_tensor(inp_t) + inp_png = self._to_png_bytes(inp_img).numpy() + inp_b64 = base64.b64encode(inp_png).decode() + parts.append(self._image_panel("Input", inp_t, inp_b64)) + + pred_t = self._get_pred_tensor(step_result) + if pred_t is not None: + pred_img = self._to_image_tensor(pred_t) + pred_png = self._to_png_bytes(pred_img).numpy() + pred_b64 = base64.b64encode(pred_png).decode() + parts.append(self._image_panel("Predicted", pred_t, pred_b64)) + parts.append("
") + + # Row 3: Loss convergence curve + if len(self._g_losses) > 1: + parts.append(self._loss_svg()) + + clear_output(wait=True) + display(HTML("\n".join(parts))) + + def _loss_svg(self): + """Render a compact inline SVG loss chart with axes (no matplotlib).""" + w, h = 600, 160 + left, right, top, bottom = 60, 20, 15, 40 + plot_w = w - left - right + plot_h = h - top - bottom + n = len(self._g_losses) + if n < 2: + return "" + + # Combine both loss series to get a shared Y range + all_vals = self._g_losses + self._d_losses + ymin, ymax = min(all_vals), max(all_vals) + if ymax == ymin: + ymax = ymin + 1 + + def _map(vals): + xs = [left + i / (n - 1) * plot_w for i in range(n)] + ys = [top + (1 - (v - ymin) / (ymax - ymin)) * plot_h for v in vals] + return xs, ys + + gx, gy = _map(self._g_losses) + dx, dy = _map(self._d_losses) + + g_pts = " ".join(f"{x:.1f},{y:.1f}" for x, y in zip(gx, gy)) + d_pts = " ".join(f"{x:.1f},{y:.1f}" for x, y in zip(dx, dy)) + + # X-axis tick labels (step numbers) + n_xticks = min(5, n) + xticks = "" + for i in range(n_xticks): + idx = int(i / (n_xticks - 1) * (n - 1)) if n_xticks > 1 else 0 + x_pos = left + idx / (n - 1) * plot_w + step_num = idx # monitor step index + xticks += ( + f'' + f'{step_num}' + ) + + # Y-axis tick labels (loss values) + n_yticks = 4 + yticks = "" + for i in range(n_yticks): + frac = i / (n_yticks - 1) + y_pos = top + (1 - frac) * plot_h + val = ymin + frac * (ymax - ymin) + yticks += ( + f'' + f'{val:.3g}' + ) + + svg = ( + f'' + # Axes + f'' + f'' + # Tick marks + f'{xticks}{yticks}' + # Axis labels + f'Step' + f'Loss' + # Data lines + f'' + f'' + # Legend + f'' + f'' + f'G: {self._g_losses[-1]:.4f}' + f'' + f'' + f'D: {self._d_losses[-1]:.4f}' + f'' + ) + return svg + + # ------------------------------------------------------------------ # + # Public API + # ------------------------------------------------------------------ # + def update_plot(self, step_result): + """Write summaries and optionally display inline in Jupyter. + + Parameters + ---------- + step_result : dict + Dictionary returned by ``recon_step``. + """ + self.step += 1 + step = self.step + + # Accumulate losses (cheap — just float scalars) + self._g_losses.append(float(step_result["g_loss"].numpy())) + self._d_losses.append(float(step_result["d_loss"].numpy())) + + with self.writer.as_default(): + # ---- Scalar summaries (every step) ---- + g_loss = step_result["g_loss"] + d_loss = step_result["d_loss"] + tf.summary.scalar("loss/generator", g_loss, step=step) + tf.summary.scalar("loss/discriminator", d_loss, step=step) + + if "g_gnorm" in step_result: + tf.summary.scalar("grad_norm/generator", + step_result["g_gnorm"], step=step) + if "d_gnorm" in step_result: + tf.summary.scalar("grad_norm/discriminator", + step_result["d_gnorm"], step=step) + + # ---- Image summaries + SSIM (every update_rate steps) ---- + ssim_val = 0.0 + if step % self.update_rate == 0: + # Reconstruction image + if self._recon_key in step_result: + recon_t = step_result[self._recon_key] + if self.recon_target == "tensor": + recon_t = recon_t[:, :, :, 0] if recon_t.shape.ndims == 4 else recon_t + recon_img = self._to_image_tensor(recon_t) + tf.summary.image("reconstruction", recon_img, step=step) + + # Predicted measurement + SSIM + pred_t = self._get_pred_tensor(step_result) + if pred_t is not None: + pred_img = self._to_image_tensor(pred_t) + tf.summary.image("predicted", pred_img, step=step) + + pred_2d = tf.reshape( + tf.cast(pred_t, tf.float32), + [self.img_h, self.img_w], + ) + input_tf = tf.constant(self.img_input) + ssim_val = float(self._tf_ssim(pred_2d, input_tf)[0].numpy()) + tf.summary.scalar("metrics/ssim", ssim_val, step=step) + + # Flush + inline display periodically + if step % self.update_rate == 0: + self.writer.flush() + if self._in_notebook: + self._display_inline(step_result, ssim_val) + + def close_plot(self, last_step_result=None): + """Flush, do a final inline display, and close the TensorBoard writer.""" + self.writer.flush() + # Final inline display so progress shows 100% + if self._in_notebook and last_step_result is not None: + # Compute final SSIM + ssim_val = 0.0 + pred_t = self._get_pred_tensor(last_step_result) + if pred_t is not None: + pred_2d = tf.reshape( + tf.cast(pred_t, tf.float32), + [self.img_h, self.img_w], + ) + input_tf = tf.constant(self.img_input) + ssim_val = float(self._tf_ssim(pred_2d, input_tf)[0].numpy()) + self._display_inline(last_step_result, ssim_val) + self.writer.close() - def close_plot(self): - plt.close() def display_strain_tensor(tensor, profile_index=None): """ @@ -243,6 +508,8 @@ def display_strain_tensor(tensor, profile_index=None): if tensor.shape[0] not in [3, 6]: raise ValueError("Input tensor must have 3 or 6 components in the first dimension") + import matplotlib.pyplot as plt + component_names = ( [ r"$\epsilon_{xx}$", diff --git a/ganrectorch/config.json b/ganrectorch/config.json index 793cc82..8d56139 100644 --- a/ganrectorch/config.json +++ b/ganrectorch/config.json @@ -6,7 +6,7 @@ "dropout": 0.25, "l1_ratio": 100, "g_learning_rate": 1e-3, - "d_learning_rate": 1e-3, + "d_learning_rate": 1e-5, "save_wpath": null, "init_wpath": null, "init_model": false, diff --git a/ganrectorch/ganrec.py b/ganrectorch/ganrec.py index 050410e..f694985 100644 --- a/ganrectorch/ganrec.py +++ b/ganrectorch/ganrec.py @@ -1,6 +1,8 @@ import os import json +import copy from tqdm import tqdm +import numpy as np import torch import torch.nn.functional as F from torch import nn, optim @@ -33,6 +35,64 @@ def load_config(filename): config = load_config("config.json") +class DeviceEMA: + """Exponential moving average of model parameters (on device).""" + def __init__(self, model, decay=0.999): + self.model = model + self.decay = decay + self.shadow = {name: p.data.clone() for name, p in model.named_parameters()} + self.backup = {} + + @torch.no_grad() + def update(self): + for name, p in self.model.named_parameters(): + self.shadow[name].mul_(self.decay).add_(p.data, alpha=1.0 - self.decay) + + def swap_in(self): + """Load EMA weights into the model (backup current weights).""" + self.backup = {name: p.data.clone() for name, p in self.model.named_parameters()} + for name, p in self.model.named_parameters(): + p.data.copy_(self.shadow[name]) + + def swap_out(self): + """Restore original (non-EMA) weights.""" + for name, p in self.model.named_parameters(): + p.data.copy_(self.backup[name]) + self.backup = {} + + +class DeviceSnapshot: + """On-device snapshot/restore of model state dicts.""" + def __init__(self, models): + self.models = models + self.copies = [copy.deepcopy(m.state_dict()) for m in models] + + @torch.no_grad() + def snapshot(self): + for i, m in enumerate(self.models): + self.copies[i] = copy.deepcopy(m.state_dict()) + + @torch.no_grad() + def restore(self): + for m, sd in zip(self.models, self.copies): + m.load_state_dict(sd) + + +class ScalarEMA: + """Exponential moving average of a scalar value.""" + def __init__(self, decay=0.95): + self.decay = decay + self.value = None + + def update(self, x): + x = float(x) + if self.value is None: + self.value = x + else: + self.value = self.decay * self.value + (1.0 - self.decay) * x + return self.value + + # @torch.compile() def discriminator_loss(real_output, fake_output): real_loss = torch.mean(torch.nn.BCEWithLogitsLoss()(real_output, torch.ones_like(real_output))) @@ -119,8 +179,14 @@ def make_model(self): self.prj_input.shape[2], self.prj_input.shape[3], self.conv_num, self.conv_size, self.dropout, 1 ) self.discriminator = Discriminator() - self.generator_optimizer = optim.Adam(self.generator.parameters(), lr=self.g_learning_rate) - self.discriminator_optimizer = optim.Adam(self.discriminator.parameters(), lr=self.d_learning_rate) + self.generator_optimizer = optim.AdamW( + self.generator.parameters(), lr=self.g_learning_rate, + weight_decay=1e-4, betas=(0.9, 0.99) + ) + self.discriminator_optimizer = optim.AdamW( + self.discriminator.parameters(), lr=self.d_learning_rate, + weight_decay=1e-4, betas=(0.9, 0.99) + ) # @torch.compile() def nor_tomo(self, data): @@ -140,7 +206,6 @@ def nor_tomo(self, data): return shifted_data - @torch.compile() def recon_step(self, prj, ang): self.generator_optimizer.zero_grad() self.discriminator_optimizer.zero_grad() @@ -149,8 +214,6 @@ def recon_step(self, prj, ang): recon = self.nor_tomo(recon) prj_rec = self.radon(recon, ang) prj_rec = self.nor_tomo(prj_rec) - # print(f"prj shape: {prj.shape}") - # print(f"prj_rec shape: {prj_rec.shape}") real_output = self.discriminator(prj) fake_output = self.discriminator(prj_rec) g_loss = generator_loss(fake_output, prj, prj_rec, self.l1_ratio) @@ -160,6 +223,14 @@ def recon_step(self, prj, ang): self.scaler.scale(g_loss).backward(retain_graph=True) self.scaler.scale(d_loss).backward() + # Unscale before clipping + self.scaler.unscale_(self.generator_optimizer) + self.scaler.unscale_(self.discriminator_optimizer) + + # Gradient clipping for stability + torch.nn.utils.clip_grad_norm_(self.generator.parameters(), max_norm=1.0) + torch.nn.utils.clip_grad_norm_(self.discriminator.parameters(), max_norm=1.0) + # Optimizer step with gradient scaling self.scaler.step(self.generator_optimizer) self.scaler.step(self.discriminator_optimizer) @@ -178,43 +249,404 @@ def recon(self): self.generator.load_state_dict(torch.load(self.init_wpath + "generator.pth")) print("generator is initialized") self.discriminator.load_state_dict(torch.load(self.init_wpath + "discriminator.pth")) - recon = torch.zeros((self.iter_num, 1, self.px, self.px)) - gen_loss = torch.zeros((self.iter_num)) - ########################################################################### - # Reconstruction process monitor + # ---------- Stability tunables ---------- + ema_decay = float(getattr(self, "ema_decay", 0.99)) + snapshot_every = int(getattr(self, "snapshot_every", 50)) + log_every = int(getattr(self, "log_every", 10)) + spike_factor = float(getattr(self, "spike_factor", 1.5)) + warmup_steps = int(getattr(self, "warmup_steps", max(5, self.iter_num // 20))) + lr_backoff = float(getattr(self, "lr_backoff", 0.5)) + lr_floor = float(getattr(self, "lr_floor", 1e-6)) + freeze_disc_max = int(getattr(self, "freeze_disc_max", 10)) + + # ---------- EMA & Snapshots ---------- + ema = DeviceEMA(self.generator, decay=ema_decay) + snap = DeviceSnapshot([self.generator, self.discriminator]) + snap.snapshot() + + g_ema = ScalarEMA(0.95) + d_ema = ScalarEMA(0.95) + + g_lr0 = self.g_learning_rate + d_lr0 = self.d_learning_rate + freeze_disc_steps = 0 + + # ---------- Monitor ---------- + gen_loss = torch.zeros(self.iter_num) if self.recon_monitor: plot_x, plot_loss = [], [] recon_monitor = RECONmonitor("tomo", self.prj_input.cpu()) - pbar = tqdm(total=self.iter_num, desc="Reconstruction Progress", position=0, leave=True) + pbar = tqdm(total=self.iter_num, desc="Reconstruction Progress", position=0, leave=True) - ########################################################################### + recon = None for epoch in range(self.iter_num): - - ########################################################################### - ## Call the rconstruction step - + # Apply temporary D freeze by setting LR to 0 + if freeze_disc_steps > 0: + for pg in self.discriminator_optimizer.param_groups: + pg["lr"] = 0.0 + freeze_disc_steps -= 1 + else: + for pg in self.discriminator_optimizer.param_groups: + pg["lr"] = d_lr0 + + # ---------- Reconstruction step ---------- step_result = self.recon_step(self.prj_input, self.angle) + g_loss_val = step_result["g_loss"].item() + d_loss_val = step_result["d_loss"].item() recon = step_result["recon"] - gen_loss[epoch] = step_result["g_loss"] - ########################################################################### + + # ---------- NaN / Inf guard ---------- + if not (np.isfinite(g_loss_val) and np.isfinite(d_loss_val)): + snap.restore() + # Backoff learning rates + for pg in self.generator_optimizer.param_groups: + pg["lr"] = max(lr_floor, pg["lr"] * lr_backoff) + for pg in self.discriminator_optimizer.param_groups: + pg["lr"] = max(lr_floor, pg["lr"] * lr_backoff) + freeze_disc_steps = freeze_disc_max + pbar.set_postfix_str("NaN/Inf→rollback") + pbar.update(1) + continue + + # ---------- Flat output guard ---------- + if epoch % log_every == 0: + recon_var = torch.var(recon).item() + if recon_var < 1e-4: + snap.restore() + for pg in self.discriminator_optimizer.param_groups: + pg["lr"] = max(lr_floor, pg["lr"] * lr_backoff) + pbar.set_postfix_str("flat→rollback") + pbar.update(1) + continue + + # ---------- Update scalar EMAs ---------- + g_bar = g_ema.update(g_loss_val) + d_bar = d_ema.update(d_loss_val) + + # ---------- Spike detection ---------- + if epoch > warmup_steps and epoch % log_every == 0: + if (g_loss_val > spike_factor * max(1e-8, g_bar) or + d_loss_val > spike_factor * max(1e-8, d_bar)): + snap.restore() + for pg in self.generator_optimizer.param_groups: + pg["lr"] = max(lr_floor, pg["lr"] * lr_backoff) + for pg in self.discriminator_optimizer.param_groups: + pg["lr"] = max(lr_floor, pg["lr"] * lr_backoff) + freeze_disc_steps = freeze_disc_max + pbar.set_postfix_str("spike→rollback") + pbar.update(1) + continue + + # ---------- Good step: update EMA and snapshot ---------- + ema.update() + if epoch % snapshot_every == 0: + snap.snapshot() + + gen_loss[epoch] = g_loss_val + + # ---------- Logging / monitor ---------- if self.recon_monitor: plot_x.append(epoch) plot_loss = gen_loss[: epoch + 1] - pbar.set_postfix(G_loss=gen_loss[epoch].item(), D_loss=step_result["d_loss"].item()) - pbar.update(1) + pbar.set_postfix(G_loss=f"{g_loss_val:.4f}", D_loss=f"{d_loss_val:.4f}") + pbar.update(1) + if (epoch + 1) % 100 == 0: if self.recon_monitor: prj_rec = step_result["prj_rec"].view(self.nang, self.px) prj_diff = torch.abs(prj_rec - self.prj_input.view((self.nang, self.px))).cpu() rec_plt = recon.view(self.px, self.px).cpu() recon_monitor.update_plot(epoch, prj_diff, rec_plt, plot_x, plot_loss.cpu()) - # print('Iteration {}: G_loss is {} and D_loss is {}'.format(epoch + 1, - # gen_loss[epoch], - # step_result['d_loss'].item())) - if self.save_wpath != None: + + pbar.close() + + if self.save_wpath is not None: torch.save(self.generator.state_dict(), self.save_wpath + "generator.pth") torch.save(self.discriminator.state_dict(), self.save_wpath + "discriminator.pth") if self.recon_monitor: recon_monitor.close_plot() - return tensor_to_np(recon.cpu()) + + # Final output: use EMA weights for the result + ema.swap_in() + try: + with torch.no_grad(): + final_recon = self.generator(self.prj_input) + final_recon = self.nor_tomo(final_recon) + finally: + ema.swap_out() + + return tensor_to_np(final_recon.cpu()) + + +class GANrec: + """ + General-purpose GAN-based reconstruction with full stability features. + + Unlike specialized classes (GANtomo, etc.), GANrec is not restricted + to a specific forward model. The user provides a ``forward_fn`` callable + that defines the physics mapping from reconstruction to measurement space. + + Stability features: + EMA of generator weights, on-device rollback snapshots, NaN/Inf guards, + loss-spike detection, discriminator freezing, gradient clipping, and + learning-rate backoff. + + Parameters + ---------- + input_data : ndarray (2-D) + Measured data (sinogram, intensity image, diffraction pattern, …). + forward_fn : callable + ``(gen_output, input_tensor) -> dict`` + Must return a dict with at least ``"predicted"`` (simulated measurement, + same shape as *input_tensor*). May include extra keys (``"recon"``, + ``"phase"``, …) carried through for monitoring / output extraction. + output_num : int, optional + Number of generator output channels (default 1). + output_key : str, optional + Key in ``forward_fn`` result for the final output (default ``"recon"``). + shape_output : tuple, optional + Output reshape target. Defaults to ``(input_data.shape[-1],) * 2``. + **kwargs + Override config values (``iter_num``, ``l1_ratio``, ``g_learning_rate``, + ``d_learning_rate``, ``conv_num``, …). + + Examples + -------- + **Tomography**:: + + from ganrectorch.propagators import RadonTransform + + radon = RadonTransform(torch.empty(1, 1, px, px), angle) + radon = radon.to(device) + + def tomo_forward(gen_output, input_tensor): + recon = nor_tomo(gen_output) + prj_rec = radon(recon, angle) + prj_rec = nor_tomo(prj_rec) + return {"recon": recon, "predicted": prj_rec} + + gan = GANrec(sinogram, tomo_forward) + result = gan.recon() + + **Phase retrieval (Fresnel)**:: + + from ganrectorch.propagators import PhaseFresnel + + def phase_forward(gen_output, input_tensor): + phase = normalize_phase(gen_output[:, 0:1, :, :]) + absorption = normalize_phase(gen_output[:, 1:2, :, :]) * abs_ratio + i_rec = PhaseFresnel(phase[0,0], absorption[0,0], ff, px).compute() + return {"phase": phase, "absorption": absorption, "predicted": i_rec} + + gan = GANrec(intensity, phase_forward, output_num=2, output_key="phase") + result = gan.recon() + """ + + def __init__(self, input_data, forward_fn, output_num=1, output_key="recon", + shape_output=None, **kwargs): + base_args = config["GANtomo"].copy() + base_args.update(kwargs) + super().__init__() + torch_configures() + self.scaler = GradScaler() + self.forward_fn = forward_fn + self.output_num = output_num + self.output_key = output_key + + # Store and prepare input + self.input_data = input_data + self.input_shape = input_data.shape + self.shape_output = shape_output or (self.input_shape[-1], self.input_shape[-1]) + self.input_tensor = ( + torch.from_numpy(input_data).float().unsqueeze(0).unsqueeze(0) + ) # [1, 1, H, W] + + self.iter_num = base_args["iter_num"] + self.conv_num = base_args["conv_num"] + self.conv_size = base_args["conv_size"] + self.dropout = base_args["dropout"] + self.l1_ratio = base_args["l1_ratio"] + self.g_learning_rate = base_args["g_learning_rate"] + self.d_learning_rate = base_args["d_learning_rate"] + self.save_wpath = base_args["save_wpath"] + self.init_wpath = base_args["init_wpath"] + self.init_model = base_args["init_model"] + self.recon_monitor = base_args["recon_monitor"] + self.generator = None + self.discriminator = None + + def make_model(self): + h, w = self.input_shape + self.generator = Generator(h, w, self.conv_num, self.conv_size, + self.dropout, self.output_num) + self.discriminator = Discriminator() + self.generator_optimizer = optim.AdamW( + self.generator.parameters(), lr=self.g_learning_rate, + weight_decay=1e-4, betas=(0.9, 0.99), + ) + self.discriminator_optimizer = optim.AdamW( + self.discriminator.parameters(), lr=self.d_learning_rate, + weight_decay=1e-4, betas=(0.9, 0.99), + ) + + def recon_step(self, input_tensor): + self.generator_optimizer.zero_grad() + self.discriminator_optimizer.zero_grad() + + with autocast(): + gen_output = self.generator(input_tensor) + fwd = self.forward_fn(gen_output, input_tensor) + predicted = fwd["predicted"] + real_output = self.discriminator(input_tensor) + fake_output = self.discriminator(predicted) + g_loss = generator_loss(fake_output, input_tensor, predicted, + self.l1_ratio) + d_loss = discriminator_loss(real_output, fake_output) + + self.scaler.scale(g_loss).backward(retain_graph=True) + self.scaler.scale(d_loss).backward() + self.scaler.unscale_(self.generator_optimizer) + self.scaler.unscale_(self.discriminator_optimizer) + torch.nn.utils.clip_grad_norm_(self.generator.parameters(), max_norm=1.0) + torch.nn.utils.clip_grad_norm_(self.discriminator.parameters(), max_norm=1.0) + self.scaler.step(self.generator_optimizer) + self.scaler.step(self.discriminator_optimizer) + self.scaler.update() + + fwd["g_loss"] = g_loss + fwd["d_loss"] = d_loss + return fwd + + def recon(self): + """Run the full reconstruction loop with stability safeguards. + + Returns + ------- + ndarray + Reconstruction reshaped to ``shape_output``. + """ + self.make_model() + self.input_tensor, self.generator, self.discriminator = to_device( + [self.input_tensor, self.generator, self.discriminator] + ) + + if self.init_wpath: + self.generator.load_state_dict( + torch.load(self.init_wpath + "generator.pth")) + self.discriminator.load_state_dict( + torch.load(self.init_wpath + "discriminator.pth")) + print("Models are initialized") + + # ---------- Stability tunables ---------- + ema_decay = float(getattr(self, "ema_decay", 0.99)) + snapshot_every = int(getattr(self, "snapshot_every", 50)) + log_every = int(getattr(self, "log_every", 10)) + spike_factor = float(getattr(self, "spike_factor", 1.5)) + warmup_steps = int(getattr(self, "warmup_steps", + max(5, self.iter_num // 20))) + lr_backoff = float(getattr(self, "lr_backoff", 0.5)) + lr_floor = float(getattr(self, "lr_floor", 1e-6)) + freeze_disc_max = int(getattr(self, "freeze_disc_max", 10)) + + # ---------- EMA & Snapshots ---------- + ema = DeviceEMA(self.generator, decay=ema_decay) + snap = DeviceSnapshot([self.generator, self.discriminator]) + snap.snapshot() + + g_ema_s = ScalarEMA(0.95) + d_ema_s = ScalarEMA(0.95) + d_lr0 = self.d_learning_rate + freeze_disc_steps = 0 + + # ---------- Monitor ---------- + pbar = tqdm(total=self.iter_num, desc="Reconstruction", leave=True) + + step_result = {} + for step in range(self.iter_num): + # D freeze management + if freeze_disc_steps > 0: + for pg in self.discriminator_optimizer.param_groups: + pg["lr"] = 0.0 + freeze_disc_steps -= 1 + else: + for pg in self.discriminator_optimizer.param_groups: + pg["lr"] = d_lr0 + + # ---- step ---- + step_result = self.recon_step(self.input_tensor) + g_loss_val = step_result["g_loss"].item() + d_loss_val = step_result["d_loss"].item() + + # NaN / Inf guard + if not (np.isfinite(g_loss_val) and np.isfinite(d_loss_val)): + snap.restore() + for pg in self.generator_optimizer.param_groups: + pg["lr"] = max(lr_floor, pg["lr"] * lr_backoff) + for pg in self.discriminator_optimizer.param_groups: + pg["lr"] = max(lr_floor, pg["lr"] * lr_backoff) + freeze_disc_steps = freeze_disc_max + pbar.set_postfix_str("NaN/Inf→rollback") + pbar.update(1) + continue + + # Flat-output guard + if self.output_key in step_result and step % log_every == 0: + if torch.var(step_result[self.output_key]).item() < 1e-4: + snap.restore() + for pg in self.discriminator_optimizer.param_groups: + pg["lr"] = max(lr_floor, pg["lr"] * lr_backoff) + pbar.set_postfix_str("flat→rollback") + pbar.update(1) + continue + + # Update scalar EMAs + g_bar = g_ema_s.update(g_loss_val) + d_bar = d_ema_s.update(d_loss_val) + + # Spike detection + if step > warmup_steps and step % log_every == 0: + if (g_loss_val > spike_factor * max(1e-8, g_bar) or + d_loss_val > spike_factor * max(1e-8, d_bar)): + snap.restore() + for pg in self.generator_optimizer.param_groups: + pg["lr"] = max(lr_floor, pg["lr"] * lr_backoff) + for pg in self.discriminator_optimizer.param_groups: + pg["lr"] = max(lr_floor, pg["lr"] * lr_backoff) + freeze_disc_steps = freeze_disc_max + pbar.set_postfix_str("spike→rollback") + pbar.update(1) + continue + + # Good step + ema.update() + if step % snapshot_every == 0: + snap.snapshot() + + if step % log_every == 0: + pbar.set_postfix(G_loss=f"{g_loss_val:.4f}", + D_loss=f"{d_loss_val:.4f}") + pbar.update(1) + + pbar.close() + + if self.save_wpath is not None: + torch.save(self.generator.state_dict(), + self.save_wpath + "generator.pth") + torch.save(self.discriminator.state_dict(), + self.save_wpath + "discriminator.pth") + + # ---------- Final output with EMA weights ---------- + ema.swap_in() + try: + with torch.no_grad(): + gen_output = self.generator(self.input_tensor) + fwd = self.forward_fn(gen_output, self.input_tensor) + if self.output_key in fwd: + final = fwd[self.output_key] + else: + final = fwd["predicted"] + finally: + ema.swap_out() + + return tensor_to_np(final.cpu()).reshape(self.shape_output)