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

\n",
+ "
\n",
+ "\n",
+ "
Input

\n",
+ "
Predicted

\n",
+ "
\n",
+ ""
+ ],
+ "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

\n",
+ "
\n",
+ "\n",
+ "
Input

\n",
+ "
Predicted

\n",
+ "
\n",
+ ""
+ ],
+ "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

\n",
+ "
\n",
+ "\n",
+ "
Input

\n",
+ "
Predicted

\n",
+ "
\n",
+ ""
+ ],
+ "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'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''
+ )
+ 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)