package com.elphel.imagej.cuas.rt;

import java.awt.Rectangle;
import java.nio.file.Path;
import java.text.SimpleDateFormat; // By Claude on 06/11/2026
import java.util.Calendar;         // By Claude on 06/11/2026

import com.elphel.imagej.cameras.CLTParameters;
import com.elphel.imagej.cameras.EyesisCorrectionParameters.CorrectionParameters;
import com.elphel.imagej.common.ShowDoubleFloatArrays;
import com.elphel.imagej.cuas.CuasMotion;
import com.elphel.imagej.cuas.CuasRanging;
import com.elphel.imagej.ims.UasLogReader;
import com.elphel.imagej.tileprocessor.QuadCLT;
import com.elphel.imagej.tileprocessor.QuadCLTCPU;

import ij.ImagePlus;
import ij.ImageStack;

public class CuasDetectRT {
	public static final String SUFFIX_FPIXELS_TIFF = "-CUAS-MERGED-CUAS.tiff";
	public static final String SUFFIX_SYNTH_TIFF =   "-CUAS-SYNTHETIC-CUAS.tiff"; // synthetic input (curt_synth_src) // By Claude on 06/11/2026
	public static final String SUFFIX_LOG = "-LoG";
	public static final String SUFFIX_CONV2D = "-CONV2D";
	public static final String SUFFIX_CONV3D3 = "-CONV3D3";
	public static final String SUFFIX_CONV5D =  "-CONV5D";
	public static final String SUFFIX_RECUR =   "-RECUR"; // By Claude on 06/09/2026
	// ROI override for synthetic input: covers the radial 11x11 target block of   // By Claude on 06/11/2026
	// gen_synth_cuas.py (center (320.5,256.5), step 30 -> +/-150 px) + kernel
	// margin, so the stored curt_save_select parameter keeps matching the REAL image.
	public static final Rectangle SYNTH_ROI = new Rectangle(160, 96, 320, 320); // By Claude on 06/11/2026
	public static final String [] CONV3D3_MODES = {"plain", "winner", "power"};
	public static final int CONV3D3_MODE_PLAIN =  0;
	public static final int CONV3D3_MODE_WINNER = 1;
	public static final int CONV3D3_MODE_POWER =  2;
	public static final int MAXDECIMALS =         4;
	
	CuasRanging     cuasRanging;
	UasLogReader    uasLogReader;
	CLTParameters   clt_parameters;
	String          model_directory;
//	String []       model_names;
	QuadCLT         master_CLT;
	String          fpixels_file = null;
	String          base_name = null; // add suffix and 
	float [][]      fpixels; 
	double [][]     dpixels; // same as fpixels for faster calculations on 64-bit processors 
	int             width;
	int             height;
	String []       time_stamps;
	float [][]      synth_pixels = null; // synthetic reference grid (scaled, NOT LoG'd), injected per pyramid level; null = real-only // By Claude on 06/14/2026
	double          infinity;
	
	public CuasDetectRT(
			CLTParameters     clt_parameters,
			UasLogReader      uasLogReader,
			String            model_directory,
			int               debugLevel) {
		this.uasLogReader =   uasLogReader;
		this.clt_parameters = clt_parameters;
		this.model_directory = model_directory;
		this.infinity = clt_parameters.imp.cuas_infinity;
		// The REAL stack is always the pyramid base (gives the full-length levels); the synthetic // By Claude on 06/14/2026
		// reference grid (if curt_synth_src) is loaded separately below and injected per-level.
		final String fpixels_suffix = SUFFIX_FPIXELS_TIFF; // By Claude on 06/14/2026
		String [] fpixels_paths = CorrectionParameters.getFilesByExtensionAsArray(model_directory, fpixels_suffix); // By Claude on 06/11/2026
		
		String [] dbg_slices=null;
		float [][] dbg_pixels=null;
		boolean save_copy = false; // true;
		if (fpixels_paths.length > 0) {
			fpixels_file = newestFile(fpixels_paths); // pick up the newest matching file // By Claude on 06/14/2026
			if (fpixels_paths.length > 1) {
				System.out.println((fpixels_paths.length)+" files ending with \""+fpixels_suffix+"\" found in "+model_directory+", using the newest: "+fpixels_file); // By Claude on 06/14/2026
			}
			ImagePlus imp = new ImagePlus(fpixels_file);
			width =  imp.getWidth();
			height = imp.getHeight();
			if (width > 0) {
					ImageStack stack = imp.getStack();
					// Skip non-ts ("average") slices;
					int first_slice = 1;
					int num_slices = stack.getSize();
					for (; !Character.isDigit(stack.getSliceLabel(first_slice).charAt(0)); first_slice++);
					time_stamps = new String [num_slices - first_slice + 1];
					if (save_copy) {
						dbg_slices = new String [num_slices];
						dbg_pixels = new float [num_slices][];
						for (int i = 0; i < dbg_slices.length; i++) {
							dbg_slices[i] = stack.getSliceLabel(1 + i);
							dbg_pixels[i] = (float[]) stack.getPixels (1 + i);
						}
					}
					fpixels = new float [time_stamps.length][width*height];
					for (int i = 0; i < time_stamps.length; i++) {
						time_stamps[i] = stack.getSliceLabel(first_slice + i);
						fpixels[i] = (float[]) stack.getPixels (first_slice + i);
					}
					String fpixels_name = Path.of(fpixels_file).getFileName().toString();
					base_name = fpixels_name.substring(0,fpixels_name.length() - fpixels_suffix.length()); // By Claude on 06/11/2026
					if (clt_parameters.imp.curt_subtract_avg) base_name += "-SUBAVG"; // distinct output filenames for average-subtracted runs (don't overwrite) // By Claude on 06/14/2026
					if (clt_parameters.imp.curt_synth_src) { // load the synthetic reference grid separately (NOT mixed here) // By Claude on 06/14/2026
						// Real (loaded above) is the pyramid base. The synthetic file holds NORMALIZED
						// (peak 1) clean targets / a velocity-reference grid: load + scale it, keep it
						// aside (NO LoG - it has no slowly-varying background), and inject it TILED into
						// each pyramid level just before conv5d/DNN, so it is never averaged across levels
						// and looks the same at every level while the real noise is averaged (SNR up ~sqrt2/level).
						double synth_scale = clt_parameters.imp.curt_synth_scale;
						String [] synth_paths = CorrectionParameters.getFilesByExtensionAsArray(model_directory, SUFFIX_SYNTH_TIFF);
						if (synth_paths.length > 0) {
							String synth_file = newestFile(synth_paths); // pick up the newest synthetic // By Claude on 06/14/2026
							ImagePlus imp_s = new ImagePlus(synth_file);
							ImageStack ss = imp_s.getStack();
							int s_first = 1;
							for (; !Character.isDigit(ss.getSliceLabel(s_first).charAt(0)); s_first++);
							int n_synth = ss.getSize() - s_first + 1;
							synth_pixels = new float [n_synth][];
							for (int i = 0; i < n_synth; i++) {
								float [] sp = (float[]) ss.getPixels(s_first + i);
								float [] sc = new float [sp.length];
								for (int k = 0; k < sp.length; k++) sc[k] = (float)(sp[k] * synth_scale);
								synth_pixels[i] = sc;
							}
							System.out.println("Synthetic grid: loaded "+n_synth+" frames (scale "+synth_scale+", no LoG) from "+synth_file+
									" - injected per pyramid level, tiled (synth[j % "+n_synth+"])");
						} else {
							System.out.println("curt_synth_src set but no \""+SUFFIX_SYNTH_TIFF+"\" found in "+model_directory+" - real only");
						}
						base_name += "-SYNTH"+d2s(synth_scale)+"B"; // real is always the averaged background base // By Claude on 06/14/2026
					}
					System.out.println("Read image data from "+fpixels_file);
			} else {
				System.out.println("Failed to read image data from "+fpixels_file);
				fpixels_file = null;
				fpixels = null;
			}
		} else {
			System.out.println("No files ending with \""+fpixels_suffix+"\" found in "+model_directory); // By Claude on 06/11/2026
			fpixels_file = null;
			fpixels = null;
		}
		if (fpixels != null) {
			dpixels = new double [fpixels.length][width*height];
			for (int i = 0; i < fpixels.length; i++) for (int pix = 0; pix < fpixels[i].length; pix++) {
				dpixels[i][pix] = fpixels[i][pix]; 	
			}
		} else {
			dpixels = null;
		}
		// time_stamps
		if (dbg_pixels != null) {
			ImagePlus imp_copy = ShowDoubleFloatArrays.makeArrays(
					dbg_pixels, // float[][] pixels,
					getWidth(),  // int width,
					getHeight(), // int height,
					base_name+"-COPY",					
					dbg_slices); //String [] titles)
      		QuadCLTCPU.saveImagePlusInDirectory(imp_copy, model_directory);
 		}
		return;
	}
	
	public static String d2s(double d, int maxDecimalsPlaces) {
		String fmt = String.format("%%.%df", maxDecimalsPlaces);
		String s = String.format(fmt, d);
		s = s.trim();
		while (s.endsWith("0")) {
			s = s.substring(0,s.length()-1);
		}
		if (s.endsWith(".")) {
			s = s.substring(0,s.length()-1);
		}
		return s;
	}

	public static String d2s(double d) {
		return d2s(d, MAXDECIMALS);
	}

	/** Wall-clock stamp for tracing long-running stages (as in OpticalFlow.java). */
	public static String now() { // By Claude on 06/11/2026
		return new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(Calendar.getInstance().getTime());
	}

	/** Pick the most recently modified file among matches ("pick up the new one"). // By Claude on 06/14/2026 */
	private static String newestFile(String [] paths) {
		if ((paths == null) || (paths.length == 0)) return null;
		String best = paths[0];
		long bestT = new java.io.File(best).lastModified();
		for (int i = 1; i < paths.length; i++) {
			long t = new java.io.File(paths[i]).lastModified();
			if (t > bestT) { bestT = t; best = paths[i]; }
		}
		return best;
	}

	/** Parse a slice-label timestamp "sec_micro..." (ignoring any " f<idx>" / "-N" tail) to seconds. // By Claude on 06/14/2026 */
	private static double parseTs(String label) {
		if (label == null) return Double.NaN;
		try {
			int us = label.indexOf('_');
			if (us < 0) {
				int i = 0; while ((i < label.length()) && Character.isDigit(label.charAt(i))) i++;
				return (i > 0) ? Double.parseDouble(label.substring(0, i)) : Double.NaN;
			}
			long sec = Long.parseLong(label.substring(0, us));
			int j = us + 1; while ((j < label.length()) && Character.isDigit(label.charAt(j))) j++;
			double frac = (j > us + 1) ? Double.parseDouble("0." + label.substring(us + 1, j)) : 0.0;
			return sec + frac;
		} catch (Exception e) { return Double.NaN; }
	}

	/** Expand a timing-ROI spec: <=0 open; 0<v<10000 = last-4 of seconds (from the range, rollover-aware); >=10000 explicit. // By Claude on 06/14/2026 */
	private static double expandTimeSpec(double v, double minSec, boolean isEnd) {
		if (v <= 0) return isEnd ? Double.POSITIVE_INFINITY : Double.NEGATIVE_INFINITY;
		if (v >= 10000) return v; // explicit full timestamp (sub-second fraction preserved)
		long min  = (long) Math.floor(minSec);
		long high = min - (min % 10000);
		long tail = (long) Math.floor(v);   // integer seconds tail (last 4 digits)
		double frac = v - tail;             // sub-second fraction, e.g. 1/60 s steps
		long cand = high + tail;
		if (cand < min) cand += 10000;      // rolled over within the available range (...9999 -> ...+1 0000)
		if (frac > 0) return cand + frac;   // explicit sub-second given - use it for both ends
		return isEnd ? (cand + 0.999999) : (double) cand; // whole 'to' second when entered as an integer
	}

	/** [i0,i1) scene-index window for the timing ROI (curt_time_from/to), or full range if open/empty. // By Claude on 06/14/2026 */
	private int [] timeWindow(String [] ts) {
		int n = (ts == null) ? 0 : ts.length;
		double from = clt_parameters.imp.curt_time_from;
		double to   = clt_parameters.imp.curt_time_to;
		if ((n == 0) || ((from <= 0) && (to <= 0))) return new int[]{0, n};
		double minSec = Double.POSITIVE_INFINITY;
		double [] tv = new double [n];
		for (int i = 0; i < n; i++) { tv[i] = parseTs(ts[i]); if (tv[i] < minSec) minSec = tv[i]; }
		double f = expandTimeSpec(from, minSec, false);
		double t = expandTimeSpec(to,   minSec, true);
		int i0 = 0;  while ((i0 < n) && (tv[i0] <  f)) i0++;
		int i1 = i0; while ((i1 < n) && (tv[i1] <= t)) i1++;
		if (i0 >= n) { i0 = Math.max(0, n - 1); i1 = n; } // window past end - keep last scene
		if (i1 <= i0) i1 = Math.min(n, i0 + 1);           // keep at least one scene
		return new int[]{i0, i1};
	}

	/** Shallow sub-range of a rendered-ROI array / its labels for the timing ROI (no copy if full). // By Claude on 06/14/2026 */
	private static double [][][][] win4(double [][][][] a, int [] w) {
		return ((w[0] == 0) && (w[1] == a.length)) ? a : java.util.Arrays.copyOfRange(a, w[0], w[1]);
	}
	private static String [] winS(String [] a, int [] w) {
		return ((w[0] == 0) && (w[1] == a.length)) ? a : java.util.Arrays.copyOfRange(a, w[0], w[1]);
	}

	/** Re-bin each ROI pixel's velocity vector to its fractional offset position (px+dx, py+dy) via // By Claude on 06/14/2026
	 *  bilinear splat, so neighbours that both claim one sub-pixel target reinforce in the same bin.
	 *  field[roi_pix][nsub][nvel]; off[roi_pix][{dx,dy,s}] (may be null). Returns a new field. */
	private static double [][][] splatField(double [][][] field, double [][] off, Rectangle roi) {
		int rw = roi.width, rh = roi.height, npix = field.length;
		int nsub = field[0].length, nvel = field[0][0].length;
		double [][][] out = new double [npix][nsub][nvel];
		for (int p = 0; p < npix; p++) {
			if ((off == null) || (off[p] == null)) continue;
			double fx = (p % rw) + off[p][0], fy = (p / rw) + off[p][1];
			int x0 = (int) Math.floor(fx), y0 = (int) Math.floor(fy);
			double wx = fx - x0, wy = fy - y0;
			for (int gy = 0; gy < 2; gy++) for (int gx = 0; gx < 2; gx++) {
				int tx = x0 + gx, ty = y0 + gy;
				if ((tx < 0) || (tx >= rw) || (ty < 0) || (ty >= rh)) continue;
				double wgt = (gx == 0 ? 1.0 - wx : wx) * (gy == 0 ? 1.0 - wy : wy);
				if (wgt <= 0) continue;
				double [][] dst = out[ty * rw + tx], src = field[p];
				for (int su = 0; su < nsub; su++) for (int v = 0; v < nvel; v++) dst[su][v] += wgt * src[su][v];
			}
		}
		return out;
	}

	/** Level selector for the heavy per-level paths (3d3/conv5d, C5P-direct, DNN): // By Claude on 06/13/2026
	 *  empty/null curt_c5_levels = all built levels, otherwise only the listed ones. */
	private static boolean c5LevelSelected(int [] c5_levels, int nlev) { // By Claude on 06/13/2026
		if ((c5_levels == null) || (c5_levels.length == 0)) return true;
		for (int l : c5_levels) if (l == nlev) return true;
		return false;
	}

	/** "Ghostbuster": the DNN velocity grid (radius vel_radius cells) extends past the trained # By Claude on 06/15/2026
	 * velocity disk, so untrained corner cells emit spurious sidelobes (ghosts) of non-trivial
	 * strength that would confuse the recurrent layer. For each ROI pixel: zero velocity cells with
	 * cell-radius > rmax_cells (= trained vmax_px * vel_decimate); if the pixel's PEAK lands in that
	 * untrained region the whole detection is a ghost -> zero its field AND set s=0 (off[pix][2]).
	 * field = [npix][1][nvel] (softmax*s), off = [npix][{dx,dy,s}] (may be null). rmax_cells<=0 disables. */
	private static void dnnGhostbust(double [][][] field, double [][] off, int vel_radius, double rmax_cells) {
		if ((field == null) || (rmax_cells <= 0)) return;
		final int vdim = 2 * vel_radius + 1;
		final double r2 = rmax_cells * rmax_cells;
		for (int pix = 0; pix < field.length; pix++) {
			double [] fv = (field[pix] != null) ? field[pix][0] : null;
			if (fv == null) continue;
			int amax = -1; double amx = Double.NEGATIVE_INFINITY;
			for (int v = 0; v < fv.length; v++) if (fv[v] > amx) { amx = fv[v]; amax = v; }
			if (amax < 0) continue;
			int avx = amax % vdim - vel_radius, avy = amax / vdim - vel_radius;
			if ((avx * avx + avy * avy) > r2) {                 // peak is in the untrained corner -> ghost
				java.util.Arrays.fill(fv, 0.0);
				if ((off != null) && (off[pix] != null)) off[pix][2] = 0.0; // s = 0 (don't feed the recurrent)
			} else {                                            // real peak: keep it, drop the corner sidelobes
				for (int v = 0; v < fv.length; v++) {
					int vx = v % vdim - vel_radius, vy = v / vdim - vel_radius;
					if ((vx * vx + vy * vy) > r2) fv[v] = 0.0;
				}
			}
		}
	}

	/** Tag a saved CUAS image with the full run config: copy ALL curt_* params (via the config
	 * serializer, so any new curt_ param is auto-included) into the ImagePlus properties and encode
	 * them to the ImageJ Info field (XML -> TIFF ImageDescription). The python reader (python3-imagej-tiff
	 * format) decodes <key>value</key> back to a dict - so every output is self-describing. By Claude on 06/16/2026 */
	private static ImagePlus tagCuasImp(ImagePlus imp, com.elphel.imagej.tileprocessor.IntersceneMatchParameters p) {
		if ((imp == null) || (p == null)) return imp;
		java.util.Properties props = new java.util.Properties();
		p.setProperties("", props);                       // serialize all params -> props (keys "curt_..." among others)
		for (String key : props.stringPropertyNames()) {
			if (key.startsWith("curt_")) imp.setProperty(key, props.getProperty(key));
		}
		com.elphel.imagej.readers.EyesisTiff.encodeProperiesToInfo(imp); // -> imp "Info" XML -> FileSaver writes ImageDescription
		return imp;
	}

	
	public int getWidth() {
		return width;
	}
	public int getHeight() {
		return height;
	}
	String [] getTimeStamps() {
		return time_stamps;
	}

	String [] getTimeStamps(int from_indx) {
		String [] time_stamps_from = new String[time_stamps.length - from_indx];
		System.arraycopy(time_stamps, from_indx, time_stamps_from, 0, time_stamps_from.length);
		return time_stamps_from;
	}
	String [] getTimeStamps(int from_indx, int tdecimate) {
		String [] time_stamps_decimated = new String[time_stamps.length / tdecimate];
		for (int i = 0; i < time_stamps_decimated.length; i++) {
			time_stamps_decimated[i] = time_stamps[(i+1)* tdecimate - 1];
		}
		return time_stamps_decimated;
	}

	
	
	public double [][] getDPpixels(){
		return dpixels;
	}
	
	public float [][] getFPixels() {
		return fpixels;
	}
	
	public String getBaseName() {
		return base_name;
	}
	
	public String getModelDirectory() {
		return model_directory;
	}

	/**
	 * Combine several same-size RECT renderings into one hyperstack: bottom slider // By Claude on 06/11/2026
	 * selects the view type (what was previously in separate files), inner slider -
	 * timestamp, so corresponding areas of all processing stages are immediately
	 * comparable at the same timestamp without aligning sliders across windows.
	 * @param type_imps   rendered RECT stacks: [0] - full-rate (every filter step),
	 *                    [1..] - recurrent-rate (every `period`-th step)
	 * @param type_titles one per type, e.g. {"CONV5D","NEW","PRESH","RECUR"}
	 * @param ts_full     all (filter-rate) timestamps
	 * @param ts_recur    recurrent-rate timestamps
	 * @param period      filter steps per recurrent step
	 * @param full        true - a slice per filter-rate timestamp, all-NaN filler
	 *                    where recurrent data is absent (3 of 4 now); false -
	 *                    decimated to recurrent-rate timestamps (filter subsampled)
	 * @param title       result title
	 * @return hyperstack ImagePlus (not shown), or null on pixel size mismatch
	 */
	private static ImagePlus combineRect5d( // By Claude on 06/11/2026
			ImagePlus [] type_imps,
			String []    type_titles,
			String []    ts_full,
			String []    ts_recur,
			int          period,
			boolean      full,
			String       title) {
		int width =  type_imps[0].getWidth();
		int height = type_imps[0].getHeight();
		for (ImagePlus imp : type_imps) {
			if ((imp == null) || (imp.getWidth() != width) || (imp.getHeight() != height)) {
				System.out.println("combineRect5d(): missing image or pixel size mismatch - skipping combined output");
				return null;
			}
		}
		int num_types =  type_imps.length;
		int num_slices = full ? ts_full.length : ts_recur.length;
		float [] nan_slice = new float [width * height]; // shared by all empty slices (display/save only)
		java.util.Arrays.fill(nan_slice, Float.NaN);
		float [][][] combo = new float [num_types][num_slices][];
		for (int n = 0; n < num_slices; n++) {
			int n_full =  full ? n : (n * period);  // index into the full-rate stack
			int n_recur = full ? (n / period) : n;  // index into the recurrent-rate stacks
			boolean has_recur = !full || (((n % period) == 0) && (n_recur < ts_recur.length));
			combo[0][n] = (n_full < type_imps[0].getStackSize()) ?
					(float []) type_imps[0].getStack().getPixels(n_full + 1) : nan_slice;
			for (int t = 1; t < num_types; t++) {
				combo[t][n] = (has_recur && (n_recur < type_imps[t].getStackSize())) ?
						(float []) type_imps[t].getStack().getPixels(n_recur + 1) : nan_slice;
			}
		}
		return ShowDoubleFloatArrays.showArraysHyperstack(
				combo,                          // float[][][] pixels [type][timestamp][pix]
				width,                          // int          width
				title,                          // String       title
				full ? ts_full : ts_recur,      // String []    titles (slice = timestamp)
				type_titles,                    // String []    frame_titles (view type)
				false);                         // boolean      show
	}

	/**
	 * Run the Layer 2 recurrent accumulation buffer over one pyramid level's filtered // By Claude on 06/11/2026
	 * fine-velocity ROI sequence and save the debug imagery. The pyramid is temporal
	 * (same pixel size at all levels), so the ROI and the buffer geometry apply
	 * unchanged - only the time step is 2^level base units, making deeper levels
	 * sensitive to proportionally slower (far/weak) targets.
	 * Always saves: -VMAX (per-pixel {Vx,Vy,W} fractional argmax hyperstack),
	 * -CMB-FULL-RECT and -CMB-DEC-RECT (combined {CONV5D,NEW,PRESH,RECUR} views).
	 * With save_individual (level 0 legacy set) also: -RECUR-RECT,
	 * -RECUR-HYPER-RECT, -NEW-RECT, -PRESH-RECT.
	 * All curt_recur_* / velocity-geometry parameters are read from clt_parameters.
	 * @param cuasRTUtils     renderers (showConvKernel5d etc.)
	 * @param conv5d_roi      [n5d][roi_npix][nsub][nvel] - filtered convolve3d ROI output of this level
	 * @param ts_5d           timestamps of this level (filter rate)
	 * @param roi             ROI rectangle (curt_save_select)
	 * @param title_base      title prefix for this level (level suffix already included)
	 * @param save_individual also save the separate per-stage files
	 */
	private void runRecurrentLevel( // By Claude on 06/11/2026
			CuasRTUtils     cuasRTUtils,
			double [][][][] conv5d_roi,
			String []       ts_5d,
			Rectangle       roi,
			String          title_base,
			boolean         save_individual) {
		int    period =       clt_parameters.imp.curt_recur_period;
		double w =            clt_parameters.imp.curt_recur_w;
		double leak =         clt_parameters.imp.curt_recur_leak;
		double wthresh =      clt_parameters.imp.curt_recur_wthresh;
		double spread =       clt_parameters.imp.curt_recur_spread;
		int    max_clust =    clt_parameters.imp.curt_recur_max_clust;
		double fit_rat =      clt_parameters.imp.curt_recur_fit_rat;
		double rs_frac =      clt_parameters.imp.curt_recur_rs_frac;
		double rs_min =       clt_parameters.imp.curt_recur_rs_min;
		int    pix_decimate = clt_parameters.imp.curt_pix_decimate;
		int    vel_radius =   clt_parameters.imp.curt_vel_radius;
		int    vel_decimate = clt_parameters.imp.curt_vel_decimate;
		double dbg_min =      clt_parameters.imp.curt_vel_dbgmin;
		int num_5d = conv5d_roi.length;
		CuasRecurrentBuffer recurrentBuffer = new CuasRecurrentBuffer(
				roi.width,                   // int roi_width,
				roi.height,                  // int roi_height,
				pix_decimate * pix_decimate, // int num_sub,
				vel_radius,                  // int velocity_radius,
				vel_decimate,                // int velocity_decimate,
				period,                      // int period,
				spread,                      // double spread,
				max_clust,                   // int max_clust,
				fit_rat,                     // double fit_rat,
				rs_frac,                     // double rs_frac,
				rs_min);                     // double rs_min)
		recurrentBuffer.setSaveStages(true);
		int max_recur = num_5d / period + 1;
		double [][][][] recur_states = new double[max_recur][][][];
		double [][][][] recur_cond =   new double[max_recur][][][]; // conditioned measurements (new data to combine)
		double [][][][] recur_pre =    new double[max_recur][][][]; // merged states before re-sharpening
		String []       recur_ts =     new String[max_recur];
		int num_recur = 0;
		for (int n5d = 0; n5d < num_5d; n5d++) {
			if (((n5d % period) != 0) || (conv5d_roi[n5d] == null)) continue;
			recurrentBuffer.update(
					conv5d_roi[n5d], // double [][][] measurement,
					w,               // double        w,
					leak,            // double        leak,
					wthresh);        // double        wthresh)
			recur_states[num_recur] = recurrentBuffer.getBufferCopy();
			recur_cond[num_recur] =   recurrentBuffer.getLastConditioned();
			recur_pre[num_recur] =    recurrentBuffer.getPreResharpen();
			recur_ts[num_recur++] =   ts_5d[n5d];
		}
		if (num_recur == 0) {
			return;
		}
		double [][][][] recur_show =     new double[num_recur][][][];
		double [][][][] recur_cond_show = new double[num_recur][][][];
		double [][][][] recur_pre_show =  new double[num_recur][][][];
		String []       recur_ts_show =  new String[num_recur];
		System.arraycopy(recur_states, 0, recur_show,      0, num_recur);
		System.arraycopy(recur_cond,   0, recur_cond_show, 0, num_recur);
		System.arraycopy(recur_pre,    0, recur_pre_show,  0, num_recur);
		System.arraycopy(recur_ts,     0, recur_ts_show,   0, num_recur);
		String title_recur = title_base+
				"-RP"+period+"-RW"+d2s(w)+"-RL"+d2s(leak)+
				"-RWT"+d2s(wthresh)+"-RS"+d2s(spread)+
				"-RC"+max_clust+"-RF"+d2s(fit_rat)+
				"-RR"+d2s(rs_frac)+"-RM"+d2s(rs_min)+SUFFIX_RECUR+"-D"+dbg_min;
		ImagePlus imp_recur_rect = cuasRTUtils.showConvKernel5d(
				recur_show,
				roi,
				recur_ts_show,
				title_recur+"-RECT");
		ImagePlus imp_recur_new = cuasRTUtils.showConvKernel5d(
				recur_cond_show,
				roi,
				recur_ts_show,
				title_recur+"-NEW-RECT");
		ImagePlus imp_recur_presh = cuasRTUtils.showConvKernel5d(
				recur_pre_show,
				roi,
				recur_ts_show,
				title_recur+"-PRESH-RECT");
		if (save_individual) {
			QuadCLTCPU.saveImagePlusInDirectory(imp_recur_rect,  getModelDirectory());
			QuadCLTCPU.saveImagePlusInDirectory(imp_recur_new,   getModelDirectory());
			QuadCLTCPU.saveImagePlusInDirectory(imp_recur_presh, getModelDirectory());
			ImagePlus imp_recur_hyper = cuasRTUtils.showConvKernel5dHyperRect(
					recur_show,
					roi,
					recur_ts_show,
					title_recur+"-HYPER-RECT");
			QuadCLTCPU.saveImagePlusInDirectory(imp_recur_hyper, getModelDirectory());
		}
		// low-resolution summary: per ROI pixel {Vx, Vy, W} - integer argmax over the
		// velocity plane (best sub), fractional-pixel argmax by cosine-windowed local
		// centroid (Correlation2d.getMaxXYCm), W = plane maximum
		final double vmax_cm_radius = 2.0; // cosine window radius, covers the +/-1.5 bump support
		final int    vmax_cm_passes = 1;   // window re-centering passes
		final double vmax_thresh =    1.0; // min plane max to report Vx,Vy (below - NaN); W is always kept,
		                                   // so ImageJ histogram/statistics (NaN-blind) of Vx,Vy show only
		                                   // the real cluster, and the W slice guides threshold choice
		final int vel_dim_recur = 2 * vel_radius + 1;
		final int roi_npix = roi.width * roi.height;
		double [][][] recur_vmax = new double [3][num_recur][roi_npix];
		for (int n = 0; n < num_recur; n++) {
			for (int p = 0; p < roi_npix; p++) {
				double [][] subs = recur_show[n][p];
				int best_s = -1;
				double mx = 0;
				for (int s = 0; s < subs.length; s++) {
					for (int v = 0; v < subs[s].length; v++) {
						if (subs[s][v] > mx) {
							mx = subs[s][v];
							best_s = s;
						}
					}
				}
				double [] xy = ((best_s >= 0) && (mx >= vmax_thresh)) ? com.elphel.imagej.tileprocessor.Correlation2d.getMaxXYCm(
						subs[best_s],    // double []  data (not modified: fpn_mask == null)
						vel_dim_recur,   // int        data_width
						vmax_cm_radius,  // double     radius
						vmax_cm_passes,  // int        refine (window re-centering)
						null,            // boolean [] fpn_mask
						false,           // boolean    ignore_border
						false) : null;   // boolean    debug
				if (xy != null) {
					recur_vmax[0][n][p] = xy[0];
					recur_vmax[1][n][p] = xy[1];
				} else {
					recur_vmax[0][n][p] = Double.NaN;
					recur_vmax[1][n][p] = Double.NaN;
				}
				recur_vmax[2][n][p] = mx; // W always kept (0 for empty) - guides vmax_thresh choice
			}
		}
		ImagePlus imp_recur_vmax = ShowDoubleFloatArrays.showArraysHyperstack(
				recur_vmax,                     // double[][][] pixels [{Vx,Vy,W}][scene][pix]
				roi.width,                      // int          width
				title_recur+"-VMAX",            // String       title
				recur_ts_show,                  // String []    titles (scene timestamps)
				new String[] {"Vx","Vy","W"},   // String []    frame_titles
				false);                         // boolean      show
		QuadCLTCPU.saveImagePlusInDirectory(imp_recur_vmax, getModelDirectory());
		// combined RECT hyperstacks: bottom slider - view type, inner slider - timestamp
		ImagePlus imp_filter_rect = cuasRTUtils.showConvKernel5d(
				conv5d_roi,
				roi,
				ts_5d,
				title_base+"-RECT");
		ImagePlus [] cmb_imps =   {imp_filter_rect, imp_recur_new, imp_recur_presh, imp_recur_rect};
		String []    cmb_titles = {"CONV5D", "NEW", "PRESH", "RECUR"};
		ImagePlus imp_cmb_full = combineRect5d(
				cmb_imps, cmb_titles, ts_5d, recur_ts_show,
				period, true, title_recur+"-CMB-FULL-RECT");
		if (imp_cmb_full != null) {
			QuadCLTCPU.saveImagePlusInDirectory(imp_cmb_full, getModelDirectory());
		}
		ImagePlus imp_cmb_dec = combineRect5d(
				cmb_imps, cmb_titles, ts_5d, recur_ts_show,
				period, false, title_recur+"-CMB-DEC-RECT");
		if (imp_cmb_dec != null) {
			QuadCLTCPU.saveImagePlusInDirectory(imp_cmb_dec, getModelDirectory());
		}
	}

	public CuasMotion detectTargets(
			CLTParameters     clt_parameters,
			boolean           batch_mode,
			int               debugLevel) {
		boolean  save_linear_cuas =  true;
		int      curt_pyramid =      clt_parameters.imp.curt_pyramid;
		double   curt_psf_radius =   clt_parameters.imp.curt_psf_radius;
		double   curt_n_sigma =      clt_parameters.imp.curt_n_sigma;
		double   curt_rleak0 =       clt_parameters.imp.curt_rleak0;
		int      curt_pix_decimate = clt_parameters.imp.curt_pix_decimate;
		int      curt_vel_decimate = clt_parameters.imp.curt_vel_decimate;
		int      curt_vel_radius =   clt_parameters.imp.curt_vel_radius;
		boolean  curt_vel_thresh_en =clt_parameters.imp.curt_vel_thresh_en;
		int      curt_vel_suppr_rad =clt_parameters.imp.curt_vel_suppr_rad;
		double   curt_vel_athresh =  clt_parameters.imp.curt_vel_athresh;
		double   curt_vel_rthresh =  clt_parameters.imp.curt_vel_rthresh;
		double   curt_vel_dbg_min =  clt_parameters.imp.curt_vel_dbgmin; // temporary, testing with strong targets, make clean background
		boolean  curt_recur_en =     clt_parameters.imp.curt_recur_en;      // By Claude on 06/09/2026
		int      curt_recur_period = clt_parameters.imp.curt_recur_period;  // By Claude on 06/09/2026
		double   curt_recur_w =      clt_parameters.imp.curt_recur_w;       // By Claude on 06/09/2026
		double   curt_recur_leak =   clt_parameters.imp.curt_recur_leak;    // By Claude on 06/09/2026
		double   curt_recur_wthresh =clt_parameters.imp.curt_recur_wthresh; // By Claude on 06/09/2026
		double   curt_recur_spread = clt_parameters.imp.curt_recur_spread;  // By Claude on 06/09/2026
		int      curt_recur_max_clust = clt_parameters.imp.curt_recur_max_clust; // By Claude on 06/09/2026
		double   curt_recur_fit_rat = clt_parameters.imp.curt_recur_fit_rat; // By Claude on 06/10/2026
		double   curt_recur_rs_frac = clt_parameters.imp.curt_recur_rs_frac; // By Claude on 06/10/2026
		double   curt_recur_rs_min =  clt_parameters.imp.curt_recur_rs_min;  // By Claude on 06/10/2026

		double[] curt_temp_weights = clt_parameters.imp.curt_temp_weights.clone();
      	
		boolean  curt_save_c5full =  clt_parameters.imp.curt_save_c5full;
		boolean  curt_save_c5rect =  clt_parameters.imp.curt_save_c5rect;
		Rectangle curt_save_select =  clt_parameters.imp.curt_save_select;
		if (clt_parameters.imp.curt_synth_src) { // synthetic grid: HONOR the user ROI so it can be shrunk - the full-grid SYNTH_ROI (320x320) OOMs the per-velocity render (~12x expansion per axis); fall back to SYNTH_ROI only when no ROI is set // By Claude on 06/15/2026
			if (curt_save_select == null) {
				curt_save_select = new Rectangle(SYNTH_ROI);
				System.out.println("Synthetic input: no ROI set - using full-grid SYNTH_ROI = "+curt_save_select);
			} else {
				System.out.println("Synthetic input: using curt_save_select ROI = "+curt_save_select
						+ " (synthetic 11x11 grid spans ~"+SYNTH_ROI+"; aim the ROI inside it)"); // By Claude on 06/15/2026
			}
		}
        final double      min_frac_last = clt_parameters.imp.curt_min_frac; // By Claude on 06/12/2026
        if (clt_parameters.imp.curt_synth_src && (min_frac_last > 0)) { // By Claude on 06/12/2026
        	System.out.println("WARNING: synthetic (B-measurement) run with curt_min_frac = "+min_frac_last+
        			" - this NONLINEAR gate punches zeros into the strongest ridge cells; set 0 for a linear measurement");
        }
		
// Initialize LoG and other kernel parameters, calculate LoG first, show results
       	int      kernel3d3_rad = 1;
       	
       	CuasRTUtils cuasRTUtils = new CuasRTUtils (
    			curt_psf_radius,          // double   psf_radius,
				curt_n_sigma,             // double   n_sigma,
    			kernel3d3_rad,            // int      kernel3d3_rad,
    			curt_temp_weights,        // double[] temporal_weights,
                curt_pix_decimate,        // int      pixel_decimate,
                curt_vel_decimate,        // int      velocity_decimate,
                curt_vel_radius,          // int      velocity_radius,
                curt_vel_suppr_rad,       // int      vel_suppr_rad,
    			getWidth (),              // int      width,
    			getHeight(),              // int      height,
    			clt_parameters.imp.curt_c5_matched); // boolean c5_matched (matched-filter vs trapezoid direct kernel) // By Claude on 06/12/2026
       	// For testing, create whole arrays?
       	boolean save_LoG =        debugLevel> -4;
       	boolean save_5d =          debugLevel> -4;
       	boolean save_LoG_pixels = debugLevel> -4;
       	boolean save_3d3_pixels = debugLevel> -4;
   		int num_hist_5d = cuasRTUtils.getNumHist();
       	
       	if (save_LoG) {
       		String title_log = getBaseName()+SUFFIX_LOG+"-PSF"+d2s(cuasRTUtils.getPsfRadius())+"-KR"+d2s(cuasRTUtils.getKernel2dRadius());
       		ImagePlus imp_log = cuasRTUtils.showKernel2d(title_log);
       		QuadCLTCPU.saveImagePlusInDirectory(imp_log,getModelDirectory());
       	}
       	if (save_5d) {
       	    String title_5d = getBaseName()+SUFFIX_CONV5D+
       	            "-NH"+num_hist_5d+
       	            "-VR"+curt_vel_radius+"-VD"+curt_vel_decimate;
       		ImagePlus imp_5d = cuasRTUtils.showKernel5d(title_5d);
       		QuadCLTCPU.saveImagePlusInDirectory(imp_5d,getModelDirectory());
       	}
       	double [][] dpixels = getDPpixels();
       	if (clt_parameters.imp.curt_subtract_avg && (dpixels != null) && (dpixels.length > 0)) { // de-streak: subtract the static background before LoG so the sharp treeline edge (and its tile-grid ringing) is gone; the moving target is not in the average // By Claude on 06/14/2026
       		int npx = dpixels[0].length;
       		double [] mean = new double [npx];
       		for (double [] fr : dpixels) for (int p = 0; p < npx; p++) mean[p] += fr[p];
       		for (int p = 0; p < npx; p++) mean[p] /= dpixels.length;
       		for (double [] fr : dpixels) for (int p = 0; p < npx; p++) fr[p] -= mean[p];
       		System.out.println(now()+" detectTargets(): subtracted temporal average over "+dpixels.length+" input frames (curt_subtract_avg - uses whole sequence; realtime would use a prior-run average)"); // By Claude on 06/14/2026
       	}
       	double [][] dpixels_log = new double [dpixels.length][dpixels[0].length];
    	double []   kernel2d = cuasRTUtils.getKernel2d();
    	double alpha0 = 1.0 - clt_parameters.imp.curt_rleak0;
    	double alpha_pyr = 1.0 - clt_parameters.imp.curt_rleak_pyr;
    	int    curt_3d3_mode = clt_parameters.imp.curt_3d3_mode;
    	double curt_3d3_frac = clt_parameters.imp.curt_3d3_frac;
    	double curt_3d3_thrsh = clt_parameters.imp.curt_3d3_thrsh;
    	double curt_3d3_power = clt_parameters.imp.curt_3d3_power;
       	double w3d3_now =  clt_parameters.imp.curt_3d3_wnow;
       	double w3d3_prev = clt_parameters.imp.curt_3d3_wprev;
    	double alpha1 = 1.0 - clt_parameters.imp.curt_rleak1;

    	System.out.println(now()+" detectTargets(): convolve2DLReLU (LoG)"); // By Claude on 06/11/2026
    	for (int nseq = 0; nseq < dpixels_log.length; nseq++) {
       		cuasRTUtils.convolve2DLReLU(
       				dpixels[nseq],     // final double [] data,
       				kernel2d,          // final double [] kernel, // square
       				dpixels_log[nseq], // double []       result_in,
       				alpha0);            // final double    alpha)
       	}
       	if (save_LoG_pixels) {
       		String title_conv2d = getBaseName()+SUFFIX_CONV2D+"-PSF"+d2s(cuasRTUtils.getPsfRadius())+
       				"-KR"+d2s(cuasRTUtils.getKernel2dRadius())+"-ALPHA0_"+d2s(alpha0);
       		
			ImagePlus imp_conv2d = ShowDoubleFloatArrays.makeArrays(
					dpixels_log, // float[][] pixels,
					getWidth(),  // int width,
					getHeight(), // int height,
					title_conv2d,					
					getTimeStamps()); //String [] titles)
			imp_conv2d.setDisplayRange(CuasRTUtils.IMP_MINMAX[0],CuasRTUtils.IMP_MINMAX[1]);

       		QuadCLTCPU.saveImagePlusInDirectory(imp_conv2d, getModelDirectory());
       	}

       	int    dim3d3 = (2*cuasRTUtils.get3d3Radius()+1);
//       	int    size3x3 = dim3d3 * dim3d3;
       	boolean use_3d3 = clt_parameters.imp.curt_3d3_en; // bypass the 3d3 coarse-velocity path (and its ~W*H*9 arrays) when off - C5P-direct/DNN use dpixels_pyramid, not 3d3 // By Claude on 06/14/2026
       	double [][][] dpixels_3d3 = use_3d3 ? (new double [dpixels.length-1][width*height][dim3d3 * dim3d3]) : null; // By Claude on 06/14/2026
  		String title_conv3d3 = getBaseName()+SUFFIX_CONV3D3+"-PSF"+d2s(cuasRTUtils.getPsfRadius())+
   				"-KR"+d2s(cuasRTUtils.getKernel2dRadius())+"-ALPHA0_"+d2s(alpha0)+
   				"-ALPHAPYR_"+d2s(alpha_pyr)+"-M"+curt_3d3_mode+"-F"+d2s(curt_3d3_frac)+"-T"+d2s(curt_3d3_thrsh)+"-P"+d2s(curt_3d3_power)+
   				"-N"+d2s(w3d3_now)+":"+d2s(w3d3_prev)+
   				"-ALPHA1_"+d2s(alpha1);
   		// getTimeStamps(int from_indx)
   		int dbg_seq = 480;
       	if (use_3d3) { // By Claude on 06/14/2026
       	System.out.println(now()+" detectTargets(): convolve3D3LReLU"); // By Claude on 06/11/2026
       	for (int nseq = 1; nseq < dpixels_log.length; nseq++) {
       		int dbg_lev = (nseq == dbg_seq) ? 1 : -10;
       		cuasRTUtils.convolve3D3LReLU(
   				dpixels_log[nseq],   // final double [] data,
   				dpixels_log[nseq-1], // final double [] data_prev,
   				dpixels_3d3[nseq-1], // double [][]     result_in,
   				curt_3d3_mode,       // final int       mode_3d3,
   				curt_3d3_frac,       // final double   curt_3d3_frac, //  =        0.5;   // for stronger signals give this fraction of the total (of 9) weight to the 1-of-9 winner
   				curt_3d3_thrsh,      //final double   curt_3d3_thrsh, //  =       1.0;   // scale down curt_3d3_frac for weaker signals (in ratio of min(data[now]/curt_3d3_thrsh,1.0)
   				curt_3d3_power,      //final double    curt_3d3_power, //  = 2.0; // in mode_3d3=2, redistribute coarse velocities weights as the specified power of their positive values (ignore negative ones)
   				w3d3_now,            // final double    w_now, // normally 0.5, so center will propagate to the next pyramid levels
   				w3d3_prev,           // final double    w_prev,// normally 0.5, so center will propagate to the next pyramid levels
   				alpha1,              // final double    alpha) {
   				dbg_lev); // debugLevel);         // final int       debugLevel)
   		}
   		if (save_3d3_pixels) {
   			ImagePlus imp_3d3 = cuasRTUtils.render3d3Hyperstack(
   					dpixels_3d3,      // double [][][] coarse_vel_seq, // [nser][npix][nvel]
   					getTimeStamps(1), // String []     scene_names,
   					title_conv3d3);   // String        title)
       		QuadCLTCPU.saveImagePlusInDirectory(imp_3d3, getModelDirectory());
   		}
   		} else { // By Claude on 06/14/2026
   			System.out.println(now()+" detectTargets(): 3d3 coarse-velocity path bypassed (curt_3d3_en=false)"); // By Claude on 06/14/2026
   		}
   		
       	// building pyramid
   		int pyramid_levels = 	clt_parameters.imp.curt_pyramid;
   		// Single level selector for ALL heavy per-level paths (3d3/conv5d, C5P-direct, DNN): // By Claude on 06/13/2026
   		// curt_c5_levels = {} -> all built levels; {k} -> run the heavy stages only at level k
   		// (the cheap temporalAverageLReLU/convolve3D3LReLU pyramid chain still builds up to k).
   		// Synthetic input now uses the same pyramid + level selection as real data (the old
   		// pyramid_levels=1 force and the curt_synth_bg_avg N= decimation were retired).
   		final int [] c5_levels = clt_parameters.imp.curt_c5_levels;
   		
   		double [][][]   dpixels_pyramid =     new double [pyramid_levels][][];
   		String [][]     ts_pyramid =          new String [pyramid_levels][];
   		double [][][][] dpixels_3d3_pyramid = new double [pyramid_levels][][][];
   		double [][][][][] dpixels_5d_pyramid =     new double [pyramid_levels][][][][];
   		double [][][][][] dpixels_5d_roi_pyramid = new double [pyramid_levels][][][][];
   		ts_pyramid[0] =          getTimeStamps(1);
   		dpixels_3d3_pyramid[0] = dpixels_3d3;
   		dpixels_pyramid[0] = new double[dpixels_log.length-1][width*height];
		for (int nseq = 1; nseq < dpixels_log.length; nseq++) {
			cuasRTUtils.temporalAverageLReLU(
					dpixels_log[nseq],          // final double [] data_now,
					dpixels_log[nseq-1],        // final double [] data_prev,
					dpixels_pyramid[0][nseq-1], // double []       result_in,
					alpha_pyr);                 // final double    alpha)
		}
//   		System.arraycopy(dpixels_log, 1, dpixels_pyramid[0], 0, dpixels_pyramid[0].length);
//   		int center9 = 4;
   		boolean save_5d_pixels = curt_save_c5full; // debugLevel > -4;
   		String filt_suffix = curt_vel_thresh_en? ("-TE1" + "-TA"+d2s(curt_vel_athresh) + "-TR"+d2s(curt_vel_rthresh)+"-TD"+d2s(curt_vel_dbg_min)):
   			"-TE0";
   	    String title_conv5d = getBaseName()+SUFFIX_CONV5D+"-PSF"+d2s(cuasRTUtils.getPsfRadius())+
   	            "-KR"+d2s(cuasRTUtils.getKernel2dRadius())+"-A0_"+d2s(alpha0)+
   	            "-AP_"+d2s(alpha_pyr)+"-M"+curt_3d3_mode+"-F"+d2s(curt_3d3_frac)+"-T"+d2s(curt_3d3_thrsh)+"-P"+d2s(curt_3d3_power)+
   	            "-N"+d2s(w3d3_now)+":"+d2s(w3d3_prev)+
   	            "-A1_"+d2s(alpha1)+   				
   	            "-NH"+num_hist_5d+
   	            "-VR"+curt_vel_radius+"-VD"+curt_vel_decimate+"-MF"+min_frac_last+
   	            filt_suffix +
   	            "-SR"+curt_vel_suppr_rad+"-D"+curt_vel_dbg_min;
   		// Level-0 5D convolution (fine velocity from coarse-velocity history)
   		int num_5d_lev0 = Math.max(0, (dpixels.length - 1) - num_hist_5d + 1); // dpixels_3d3 length (= dpixels.length-1), valid even when 3d3 bypassed // By Claude on 06/14/2026
   		// Full whole-image conv5d render is ~num_vout * num_scenes * (W*sub)*(H*sub) * 8 B (x2 with the // By Claude on 06/14/2026
   		// compute array), and is currently an unfilled stub - guard it so it cannot OOM the heap.
   		if (curt_save_c5full && (num_5d_lev0 > 0)) {
   			int subdim = cuasRTUtils.getSubDim();
   			long full_pix = (long)(width * subdim) * (height * subdim);
   			long need = (long) cuasRTUtils.getNumVout() * num_5d_lev0 * full_pix * 8L * 2L;
   			Runtime rt = Runtime.getRuntime();
   			long headroom = rt.maxMemory() - (rt.totalMemory() - rt.freeMemory());
   			if (need > (headroom / 2)) {
   				System.out.println("WARNING: full velocities hyperstack (~"+(need >> 30)+" GB) exceeds safe heap (~"+(headroom >> 30)+
   						" GB free) - skipping it (it is also an unfilled stub; use the ROI -RECT/-HYPER-RECT outputs).");
   				curt_save_c5full = false;
   			}
   		}
   		save_5d_pixels = curt_save_c5full; // re-sync after the guard (declared above) // By Claude on 06/14/2026
   		dpixels_5d_pyramid[0] =     new double[num_5d_lev0][][][];
   		dpixels_5d_roi_pyramid[0] = new double[num_5d_lev0][][][];
   		String[] ts_5d_lev0 = new String[num_5d_lev0];
   		// Layer 2 recurrent accumulation buffer (ROI only, every pyramid level).            // By Claude on 06/11/2026
   		// convolve3d() still runs at every level time step; runRecurrentLevel() consumes    // By Claude on 06/11/2026
   		// every curt_recur_period-th output after each level's sequence is complete.        // By Claude on 06/11/2026
   		boolean run_recur = curt_recur_en && (curt_save_select != null);                      // By Claude on 06/09/2026
   		System.out.println(now()+" detectTargets(): will run convolve3d()"); // By Claude on 06/11/2026
   		if (use_3d3 && c5LevelSelected(c5_levels, 0)) { // gate the LEV0 3d3-based conv5d by curt_3d3_en + curt_c5_levels // By Claude on 06/14/2026
   		for (int n5d = 0; n5d < num_5d_lev0; n5d++) {
   			double tmp_min=curt_vel_dbg_min;
/*   			
   			if ((n5d == 0) || (n5d == 8) || (n5d == 12) || (n5d == 16)) { // disabling outputs for certain timestamps for debugging
   				tmp_min = 1000;
   			}
*/   			
   			System.out.print ("n5d="+n5d+":"+tmp_min+" ");
   			double[][][] window = new double[num_hist_5d][][];
   			for (int h = 0; h < num_hist_5d; h++) {
   				window[h] = dpixels_3d3[n5d + num_hist_5d - 1 - h];
   			}
   			if (curt_save_c5full) {
   				dpixels_5d_pyramid[0][n5d] = cuasRTUtils.convolve3d(
   						window,
   						null,
   						min_frac_last); // final double      min_frac_last){

   			}
   			if ((curt_save_c5rect || run_recur) && (curt_save_select != null)) { // By Claude on 06/09/2026 (added run_recur)
   				dpixels_5d_roi_pyramid[0][n5d] = cuasRTUtils.convolve3d(
   						window,
   						curt_save_select,
   						null,
   						min_frac_last); // final double        min_frac_last)
   				if (curt_vel_thresh_en) {
   					dpixels_5d_roi_pyramid[0][n5d] = cuasRTUtils.filterConv5dROI(
   							dpixels_5d_roi_pyramid[0][n5d],
   							curt_save_select,
   							curt_vel_athresh,
   							curt_vel_rthresh,
   							tmp_min); // curt_vel_dbg_min); // final double        dbg_min) { // zero if below, for testing with strong signals
   				}
   			}
   			ts_5d_lev0[n5d] = ts_pyramid[0][n5d + num_hist_5d - 1];
   		}
   		if (save_5d_pixels && num_5d_lev0 > 0) {
   			ImagePlus imp_5d = cuasRTUtils.showConvKernel5d(
   					dpixels_5d_pyramid[0],
   					ts_5d_lev0,
   					title_conv5d);
   			QuadCLTCPU.saveImagePlusInDirectory(imp_5d, getModelDirectory());
   		}
   		if (curt_save_c5rect && num_5d_lev0 > 0) {
   			int [] win = timeWindow(ts_5d_lev0); // timing ROI (curt_time_from/to) // By Claude on 06/14/2026
   			double [][][][] roi_w = win4(dpixels_5d_roi_pyramid[0], win); String [] ts_w = winS(ts_5d_lev0, win); // By Claude on 06/14/2026
   			ImagePlus imp_5d_rect = cuasRTUtils.showConvKernel5d(
   					roi_w,
   					curt_save_select,
   					ts_w,
   					title_conv5d+"-RECT");
   			QuadCLTCPU.saveImagePlusInDirectory(imp_5d_rect, getModelDirectory());
   			ImagePlus imp_5d_hyper = cuasRTUtils.showConvKernel5dHyperRect(
   					roi_w,
   					curt_save_select,
   					ts_w,
   					title_conv5d+"-HYPER-RECT");
   			QuadCLTCPU.saveImagePlusInDirectory(imp_5d_hyper, getModelDirectory());
   		}
   		if (run_recur && (num_5d_lev0 > 0)) {                                                 // By Claude on 06/11/2026
   			System.out.println(now()+" detectTargets(): runRecurrentLevel LEV0"); // By Claude on 06/11/2026
   			runRecurrentLevel(                                                                // By Claude on 06/11/2026
   					cuasRTUtils,                // CuasRTUtils     cuasRTUtils,              // By Claude on 06/11/2026
   					dpixels_5d_roi_pyramid[0],  // double [][][][] conv5d_roi,               // By Claude on 06/11/2026
   					ts_5d_lev0,                 // String []       ts_5d,                    // By Claude on 06/11/2026
   					curt_save_select,           // Rectangle       roi,                      // By Claude on 06/11/2026
   					title_conv5d,               // String          title_base,               // By Claude on 06/11/2026
   					true);                      // boolean         save_individual           // By Claude on 06/11/2026
   		}                                                                                     // By Claude on 06/11/2026
   		} // end LEV0 conv5d gate (curt_c5_levels) // By Claude on 06/13/2026
   		for (int nlev = 0; nlev < pyramid_levels; nlev++) {
   	       	if (save_LoG_pixels) {
   	       		String title_conv2d_lev = getBaseName()+SUFFIX_CONV2D+"-PSF"+d2s(cuasRTUtils.getPsfRadius())+
   	       				"-KR"+d2s(cuasRTUtils.getKernel2dRadius())+"-ALPHA0_"+d2s(alpha0)+"-ALPHAPYR_"+d2s(alpha_pyr)+"-LEV"+nlev;
   				ImagePlus imp_conv2d = ShowDoubleFloatArrays.makeArrays(
   						dpixels_pyramid[nlev], // float[][] pixels,
   						getWidth(),  // int width,
   						getHeight(), // int height,
   						title_conv2d_lev,					
   						ts_pyramid[nlev]); //String [] titles)
   				imp_conv2d.setDisplayRange(CuasRTUtils.IMP_MINMAX[0],CuasRTUtils.IMP_MINMAX[1]);
   	       		QuadCLTCPU.saveImagePlusInDirectory(imp_conv2d, getModelDirectory());
   	       	}
   			
   			// run next pyramid level (use half of the temporal slices)
   			if (nlev < (pyramid_levels -1)) {
   				int num_lev_scenes = (dpixels_pyramid[nlev].length)/2 -1;
   				if (num_lev_scenes < 1) { // sequence too short to build deeper levels - stop here // By Claude on 06/14/2026
   					System.out.println(now()+" detectTargets(): pyramid level "+(nlev+1)+
   							" would have "+num_lev_scenes+" frames (LEV"+nlev+" has "+dpixels_pyramid[nlev].length+
   							") - stopping pyramid build at LEV"+nlev);
   					break;
   				}
   				System.out.println(now()+" detectTargets(): building pyramid level "+(nlev+1)); // By Claude on 06/11/2026
   				dpixels_pyramid[nlev+1] = new double[num_lev_scenes][width*height];
   				
   				ts_pyramid[nlev+1] = new String[num_lev_scenes];
   				boolean lev_sel_next = use_3d3 && c5LevelSelected(c5_levels, nlev+1); // 3d3/conv5d only if enabled and this level is selected // By Claude on 06/14/2026
   				if (lev_sel_next) dpixels_3d3_pyramid[nlev+1] = new double [num_lev_scenes][width*height][dim3d3 * dim3d3]; // By Claude on 06/13/2026
   				for (int nseq = 0; nseq < num_lev_scenes; nseq++) {
   					ts_pyramid[nlev+1][nseq] = ts_pyramid[nlev][2*(nseq + 1)];
   					cuasRTUtils.temporalAverageLReLU(
   							dpixels_pyramid[nlev][2*nseq+2],          // final double [] data_now,
   							dpixels_pyramid[nlev][2*nseq],        // final double [] data_prev,
   							dpixels_pyramid[nlev+1][nseq], // double []       result_in,
   							alpha_pyr);                 // final double    alpha)
   					
   					if (lev_sel_next) cuasRTUtils.convolve3D3LReLU(                            // By Claude on 06/13/2026
   							dpixels_pyramid[nlev][2*nseq+2],   // final double [] data,
   							dpixels_pyramid[nlev][2*nseq],     // final double [] data_prev,
   							dpixels_3d3_pyramid[nlev+1][nseq], // double [][]     result_in,
   			   				curt_3d3_mode,                     // final int       mode_3d3,
   			   				curt_3d3_frac,                     // final double   curt_3d3_frac, //  =        0.5;   // for stronger signals give this fraction of the total (of 9) weight to the 1-of-9 winner
   			   				curt_3d3_thrsh,                    //final double   curt_3d3_thrsh, //  =       1.0;   // scale down curt_3d3_frac for weaker signals (in ratio of min(data[now]/curt_3d3_thrsh,1.0)
   			   				curt_3d3_power,                    //final double    curt_3d3_power, //  = 2.0; // in mode_3d3=2, redistribute coarse velocities weights as the specified power of their positive values (ignore negative ones)
   							w3d3_now,                          // final double    w_now, // normally 0.5, so center will propagate to the next pyramid levels
   							w3d3_prev,                         // final double    w_prev,// normally 0.5, so center will propagate to the next pyramid levels
   			   				alpha1,                            // final double    alpha) {
   			   				debugLevel);                       // final int       debugLevel)
   				}
   		   		if (lev_sel_next) { // gate 3d3-save + conv5d + saves + recurrent at this pyramid level (curt_c5_levels) // By Claude on 06/13/2026
   		   		if (save_3d3_pixels) {
   		   			ImagePlus imp_3d3 = cuasRTUtils.render3d3Hyperstack(
   		   					dpixels_3d3_pyramid[nlev+1],      // double [][][] coarse_vel_seq, // [nser][npix][nvel]
   		   					ts_pyramid[nlev+1], // String []     scene_names,
   		   					title_conv3d3+"-LEV"+(nlev+1));   // String        title)
   		   			QuadCLTCPU.saveImagePlusInDirectory(imp_3d3, getModelDirectory());
   		   		}
   		   		// 5D convolution for pyramid level nlev+1
   		   		int num_5d_lev = Math.max(0, dpixels_3d3_pyramid[nlev+1].length - num_hist_5d + 1);
   		   		dpixels_5d_pyramid[nlev+1] =     new double[num_5d_lev][][][];
   		   		dpixels_5d_roi_pyramid[nlev+1] = new double[num_5d_lev][][][];
   		   		String[] ts_5d_lev = new String[num_5d_lev];
   		   		for (int n5d = 0; n5d < num_5d_lev; n5d++) {
   		   			double[][][] window = new double[num_hist_5d][][];
   		   			for (int h = 0; h < num_hist_5d; h++) {
   		   				window[h] = dpixels_3d3_pyramid[nlev+1][n5d + num_hist_5d - 1 - h];
   		   			}
   		   			if (curt_save_c5full) {
   		   				dpixels_5d_pyramid[nlev+1][n5d] = cuasRTUtils.convolve3d(
   		   						window,
   		   						null,
   		   						min_frac_last); // final double      min_frac_last){
   		   			}
   		   			if ((curt_save_c5rect || run_recur) && (curt_save_select != null)) { // By Claude on 06/11/2026 (added run_recur)
   		   				dpixels_5d_roi_pyramid[nlev+1][n5d] = cuasRTUtils.convolve3d(
   		   						window,
   		   						curt_save_select,
   		   						null,
   		   						min_frac_last); // final double        min_frac_last)
   		   				if (curt_vel_thresh_en) {
   		   					dpixels_5d_roi_pyramid[nlev+1][n5d] = cuasRTUtils.filterConv5dROI(
   		   							dpixels_5d_roi_pyramid[nlev+1][n5d],
   		   							curt_save_select,
   		   							curt_vel_athresh,
   		   							curt_vel_rthresh,
   		   							curt_vel_dbg_min); // final double        dbg_min) { // zero if below, for testing with strong signals
   		   				}
   		   			}
   		   			ts_5d_lev[n5d] = ts_pyramid[nlev+1][n5d + num_hist_5d - 1];
   		   		}
   		   		if (save_5d_pixels && num_5d_lev > 0) {
   		   			ImagePlus imp_5d = cuasRTUtils.showConvKernel5d(
   		   					dpixels_5d_pyramid[nlev+1],
   		   					ts_5d_lev,
   		   					title_conv5d+"-LEV"+(nlev+1));
   		   			QuadCLTCPU.saveImagePlusInDirectory(imp_5d, getModelDirectory());
   		   		}
   		   		if (curt_save_c5rect && num_5d_lev > 0) {
   		   			int [] win = timeWindow(ts_5d_lev); // timing ROI // By Claude on 06/14/2026
   		   			double [][][][] roi_w = win4(dpixels_5d_roi_pyramid[nlev+1], win); String [] ts_w = winS(ts_5d_lev, win); // By Claude on 06/14/2026
   		   			ImagePlus imp_5d_rect = cuasRTUtils.showConvKernel5d(
   		   					roi_w,
   		   					curt_save_select,
   		   					ts_w,
   		   					title_conv5d+"-LEV"+(nlev+1)+"-RECT");
   		   			QuadCLTCPU.saveImagePlusInDirectory(imp_5d_rect, getModelDirectory());
   		   			ImagePlus imp_5d_hyper = cuasRTUtils.showConvKernel5dHyperRect(
   		   					roi_w,
   		   					curt_save_select,
   		   					ts_w,
   		   					title_conv5d+"-LEV"+(nlev+1)+"-HYPER-RECT");
   		   			QuadCLTCPU.saveImagePlusInDirectory(imp_5d_hyper, getModelDirectory());
   		   		}
   		   		if (run_recur && (num_5d_lev > 0)) {                                          // By Claude on 06/11/2026
   		   			runRecurrentLevel(                                                        // By Claude on 06/11/2026
   		   					cuasRTUtils,                     // CuasRTUtils     cuasRTUtils,   // By Claude on 06/11/2026
   		   					dpixels_5d_roi_pyramid[nlev+1],  // double [][][][] conv5d_roi,    // By Claude on 06/11/2026
   		   					ts_5d_lev,                       // String []       ts_5d,         // By Claude on 06/11/2026
   		   					curt_save_select,                // Rectangle       roi,           // By Claude on 06/11/2026
   		   					title_conv5d+"-LEV"+(nlev+1),    // String          title_base,    // By Claude on 06/11/2026
   		   					false);                          // boolean         save_individual // By Claude on 06/11/2026
   		   		}                                                                             // By Claude on 06/11/2026
   		   		} // end 3d3/conv5d gate at this pyramid level (curt_c5_levels) // By Claude on 06/13/2026
   			}
   		}
   		// --- Synthetic reference grid injection + sqrt(2) noise check ---------------------- // By Claude on 06/14/2026
   		if (clt_parameters.imp.curt_synth_src && (synth_pixels != null) && (synth_pixels.length > 0)) {
   			// sqrt(2) check: full-frame std of the PURE real frame 0 should drop ~/sqrt(2) per level.
   			for (int nlev = 0; nlev < pyramid_levels; nlev++) {
   				if ((dpixels_pyramid[nlev] == null) || (dpixels_pyramid[nlev].length == 0)) continue;
   				double [] f0 = dpixels_pyramid[nlev][0];
   				double s = 0, s2 = 0; int np = f0.length;
   				for (double v : f0) { s += v; s2 += v*v; }
   				double mean = s/np, var = s2/np - mean*mean;
   				System.out.println(String.format("   LEV%d real background std = %.5f (frame0, %d px) - expect ~/sqrt(2) per level", nlev, Math.sqrt(Math.max(0.0,var)), np));
   			}
   			// Inject the synthetic grid TILED (synth[j %% n]) into each SELECTED built level, AFTER
   			// the pure-real pyramid is complete - so it is identical at every level (never averaged
   			// across levels) while the real noise is averaged. Moving cells wrap every n_synth frames
   			// (a deliberate recurrent-decay test case). C5P/DNN read this; legacy 3d3-conv5d does not.
   			int n_synth = synth_pixels.length;
   			boolean synth_bg = clt_parameters.imp.curt_synth_bg; // false = clean targets only: drop the real background (+its noise/NaNs) before injecting // By Claude on 06/15/2026
   			for (int nlev = 0; nlev < pyramid_levels; nlev++) {
   				if (!c5LevelSelected(c5_levels, nlev)) continue;
   				double [][] lev = dpixels_pyramid[nlev];
   				if (lev == null) continue;
   				for (int j = 0; j < lev.length; j++) {
   					float [] sp = synth_pixels[j % n_synth];
   					double [] d = lev[j];
   					if (!synth_bg) java.util.Arrays.fill(d, 0.0); // clean: replace the conditioned real background with zero, then add the synthetic target(s) // By Claude on 06/15/2026
   					int m = Math.min(d.length, sp.length);
   					for (int pix = 0; pix < m; pix++) d[pix] += sp[pix];
   				}
   			}
   			System.out.println(now()+" detectTargets(): injected synthetic grid (tiled "+n_synth+" frames) into selected pyramid levels"
   					+ (synth_bg ? " (over real background)" : " (CLEAN - background zeroed, curt_synth_bg=false)")); // By Claude on 06/15/2026
   			// Save the ACTUAL DNN input after mixing: LoG-conditioned real bg (avg-subtracted if curt_subtract_avg)
   			// + injected synth, full-frame per selected level, same timestamps - what the network really sees. By Claude on 06/16/2026
   			if (save_LoG_pixels) {
   				for (int nlev = 0; nlev < pyramid_levels; nlev++) {
   					if (!c5LevelSelected(c5_levels, nlev) || (dpixels_pyramid[nlev] == null)) continue;
   					String title_mix = getBaseName()+"-SYNTHMIX"+(synth_bg?"B":"")+(clt_parameters.imp.curt_subtract_avg?"-SUBAVG":"")+"-LEV"+nlev;
   					ImagePlus imp_mix = ShowDoubleFloatArrays.makeArrays(
   							dpixels_pyramid[nlev], getWidth(), getHeight(), title_mix, ts_pyramid[nlev]);
   					imp_mix.setDisplayRange(CuasRTUtils.IMP_MINMAX[0], CuasRTUtils.IMP_MINMAX[1]);
   					QuadCLTCPU.saveImagePlusInDirectory(imp_mix, getModelDirectory());
   				}
   			}
   		}
   		// Direct pixel-domain fine-velocity path (-C5P): bypass the 3x3 coarse stage -    // By Claude on 06/11/2026
   		// a single-stage velocity-matched filter straight on the LoG+LReLU frames of     // By Claude on 06/11/2026
   		// each pyramid level (better at low SNR: conv3d3 adds ~2x background noise at    // By Claude on 06/11/2026
   		// equal contrast). Parallel to the 3d3-based path for comparison.                // By Claude on 06/11/2026
   		if (clt_parameters.imp.curt_c5_from_pix && (curt_save_select != null)) {           // By Claude on 06/11/2026
   			int num_hist_p = cuasRTUtils.getNumHistDirect();                               // By Claude on 06/11/2026
   			boolean white_en = clt_parameters.imp.curt_c5_white;                           // linear sharpening before nonlinearities // By Claude on 06/13/2026
			double  white_sp = clt_parameters.imp.curt_c5_white_sp;                        // By Claude on 06/13/2026
			double  white_vel = clt_parameters.imp.curt_c5_white_vel;                      // By Claude on 06/13/2026
			boolean recur_soft = clt_parameters.imp.curt_c5_recur_soft;                    // By Claude on 06/13/2026
   			double  recur_lambda = clt_parameters.imp.curt_c5_recur_lambda;                // operative lambda for the recurrent feed // By Claude on 06/13/2026
   			double  recur_keep = clt_parameters.imp.curt_c5_recur_keep;                    // By Claude on 06/13/2026
   			double  op_null_score = 0.5 * clt_parameters.imp.curt_c5_null_z * clt_parameters.imp.curt_c5_null_z; // By Claude on 06/13/2026
   			double [] op_log_prior = cuasRTUtils.getVelLogPrior(                           // shared by gate + sweep // By Claude on 06/13/2026
   					clt_parameters.imp.curt_c5_prior_r0, clt_parameters.imp.curt_c5_prior_r1); // By Claude on 06/13/2026
   			for (int nlev = 0; nlev < pyramid_levels; nlev++) {                            // By Claude on 06/11/2026
   				if (!c5LevelSelected(c5_levels, nlev)) continue;                           // skip non-selected levels (speed) // By Claude on 06/13/2026
   				double [][] frames = dpixels_pyramid[nlev];                                // By Claude on 06/11/2026
   				if (frames == null) continue;                                              // By Claude on 06/11/2026
   				int num_5dp = Math.max(0, frames.length - num_hist_p + 1);                 // By Claude on 06/11/2026
   				if (num_5dp == 0) continue;                                                // By Claude on 06/11/2026
   				double [][][][] c5p_roi = new double[num_5dp][][][];                       // By Claude on 06/11/2026
   				double [][][][] c5p_raw = clt_parameters.imp.curt_c5_post ?               // unfiltered copy for the Bayes posterior sweep // By Claude on 06/12/2026
   						new double[num_5dp][][][] : null;                                  // By Claude on 06/12/2026
   				String [] ts_5dp = new String[num_5dp];                                    // By Claude on 06/11/2026
   				System.out.println(now()+" detectTargets(): convolve3dDirect() LEV"+nlev+ // By Claude on 06/11/2026
   						(recur_soft ? (", recurrent feed = posterior gate (lambda="+recur_lambda+", keep="+recur_keep+")") : "")); // By Claude on 06/13/2026
   				for (int n5d = 0; n5d < num_5dp; n5d++) {                                  // By Claude on 06/11/2026
   					double [][] window = new double[num_hist_p][];                         // By Claude on 06/11/2026
   					for (int h = 0; h < num_hist_p; h++) {                                 // By Claude on 06/11/2026
   						window[h] = frames[n5d + num_hist_p - 1 - h];                      // By Claude on 06/11/2026
   					}                                                                      // By Claude on 06/11/2026
   					c5p_roi[n5d] = cuasRTUtils.convolve3dDirect(                           // By Claude on 06/11/2026
   							window,           // final double [][] data, [0] - newest     // By Claude on 06/11/2026
   							curt_save_select, // final Rectangle   roi,                   // By Claude on 06/11/2026
   							null,             // double [][][]     result_in,             // By Claude on 06/11/2026
   							min_frac_last);   // final double      min_frac_last)         // By Claude on 06/11/2026
   					if (white_en) {                                                        // linear sharpening (still linear, before all nonlinearities) // By Claude on 06/13/2026
   						c5p_roi[n5d] = cuasRTUtils.whitenC5PROI(                           // By Claude on 06/13/2026
   								c5p_roi[n5d], curt_save_select, white_sp, white_vel);      // By Claude on 06/13/2026
   					}                                                                      // By Claude on 06/13/2026
   					if (c5p_raw != null) {                                                 // By Claude on 06/12/2026
   						c5p_raw[n5d] = c5p_roi[n5d]; // raw reference stays valid - the filter/gate returns a new array; here 'raw' = whitened (so posterior images show the sharpening) // By Claude on 06/13/2026
   					}                                                                      // By Claude on 06/12/2026
   					if (recur_soft) {                                                      // lambda-controlled SOFT gate (posterior) replaces hard NMS // By Claude on 06/13/2026
   						c5p_roi[n5d] = cuasRTUtils.posteriorGateC5PROI(                    // By Claude on 06/13/2026
   								c5p_roi[n5d], curt_save_select, recur_lambda,              // By Claude on 06/13/2026
   								op_log_prior, op_null_score, recur_keep,                   // By Claude on 06/13/2026
   								clt_parameters.imp.curt_c5_post_exact);                    // By Claude on 06/13/2026
   					} else if (curt_vel_thresh_en) {                                       // By Claude on 06/11/2026
   						c5p_roi[n5d] = cuasRTUtils.filterConv5dROI(                        // By Claude on 06/11/2026
   								c5p_roi[n5d],                                              // By Claude on 06/11/2026
   								curt_save_select,                                          // By Claude on 06/11/2026
   								curt_vel_athresh,                                          // By Claude on 06/11/2026
   								curt_vel_rthresh,                                          // By Claude on 06/11/2026
   								curt_vel_dbg_min);                                         // By Claude on 06/11/2026
   					}                                                                      // By Claude on 06/11/2026
   					ts_5dp[n5d] = ts_pyramid[nlev][n5d + num_hist_p - 1];                  // By Claude on 06/11/2026
   				}                                                                          // By Claude on 06/11/2026
   				String title_c5p = title_conv5d+"-C5P"+(clt_parameters.imp.curt_c5_matched?"MK":"TK")+ // MK=matched-filter, TK=trapezoid kernel // By Claude on 06/12/2026
   						(white_en ? ("-W"+white_sp+"_"+white_vel) : "")+ // linear whitening marker // By Claude on 06/13/2026
   						(recur_soft ? ("-PG"+recur_lambda+"K"+recur_keep) : "")+ // posterior-gated recurrent feed marker // By Claude on 06/13/2026
   						((nlev > 0)? ("-LEV"+nlev) : ""); // By Claude on 06/12/2026
   				int [] win_c5p = timeWindow(ts_5dp); // timing ROI // By Claude on 06/14/2026
   				double [][][][] c5p_w = win4(c5p_roi, win_c5p); String [] ts_5dp_w = winS(ts_5dp, win_c5p); // By Claude on 06/14/2026
   				if (curt_save_c5rect) {                                                    // By Claude on 06/11/2026
   					ImagePlus imp_c5p_rect = cuasRTUtils.showConvKernel5d(                 // By Claude on 06/11/2026
   							c5p_w, curt_save_select, ts_5dp_w, title_c5p+"-RECT");         // By Claude on 06/14/2026
   					QuadCLTCPU.saveImagePlusInDirectory(imp_c5p_rect, getModelDirectory()); // By Claude on 06/11/2026
   				}                                                                          // By Claude on 06/11/2026
   				if (clt_parameters.imp.curt_save_c5hyper) {                                // per-velocity view: bottom slider = velocity, second = time // By Claude on 06/13/2026
   					ImagePlus imp_c5p_hyper = cuasRTUtils.showConvKernel5dHyperRect(       // By Claude on 06/13/2026
   							c5p_w, curt_save_select, ts_5dp_w, title_c5p+"-HYPER-RECT");   // By Claude on 06/14/2026
   					QuadCLTCPU.saveImagePlusInDirectory(imp_c5p_hyper, getModelDirectory()); // By Claude on 06/13/2026
   				}                                                                          // By Claude on 06/13/2026
   				// Bayes posterior lambda sweep: per-cell score z^2/2+ln(prior) (-SCORE),  // By Claude on 06/12/2026
   				// softmax competition over the 4D NMS window with a null-hypothesis floor //
   				// (-POST), and per-pixel marginal sum_v P (-PMARG). lambda = assumed      //
   				// input noise sigma, the "fat zero" of the competition: small -> sharp    //
   				// argmax-like posterior + amplified noise, large -> blur toward prior.    //
   				if ((c5p_raw != null) && (clt_parameters.imp.curt_c5_lambdas != null)      // By Claude on 06/12/2026
   						&& (clt_parameters.imp.curt_c5_lambdas.length > 0)) {              // By Claude on 06/12/2026
   					double [] lams = clt_parameters.imp.curt_c5_lambdas;                   // By Claude on 06/12/2026
   					double null_z = clt_parameters.imp.curt_c5_null_z;                     // By Claude on 06/12/2026
   					double null_score = 0.5 * null_z * null_z;                             // By Claude on 06/12/2026
   					int n_sweep = clt_parameters.imp.curt_c5_post_scenes;                  // 0 - all scenes // By Claude on 06/13/2026
   					int n0_sweep = ((n_sweep > 0) && (n_sweep < num_5dp)) ? (num_5dp - n_sweep) : 0; // last n_sweep scenes - bounds memory (score+post per lambda per scene) // By Claude on 06/13/2026
   					int nsc_sweep = num_5dp - n0_sweep;                                    // By Claude on 06/13/2026
   					String [] ts_sweep = java.util.Arrays.copyOfRange(ts_5dp, n0_sweep, num_5dp); // By Claude on 06/13/2026
   					System.out.println(now()+" detectTargets(): C5P posterior sweep LEV"+nlev+", "+lams.length+" lambdas, "+nsc_sweep+" scenes"); // By Claude on 06/12/2026
   					double [] log_prior = cuasRTUtils.getVelLogPrior(                      // By Claude on 06/12/2026
   							clt_parameters.imp.curt_c5_prior_r0,                           // By Claude on 06/12/2026
   							clt_parameters.imp.curt_c5_prior_r1);                          // By Claude on 06/12/2026
   					String [] lam_titles = new String [lams.length];                       // By Claude on 06/12/2026
   					double [][][][][] score_lam = new double [lams.length][nsc_sweep][][][]; // By Claude on 06/13/2026 (sweep scenes only)
   					double [][][][][] post_lam  = new double [lams.length][nsc_sweep][][][]; // By Claude on 06/13/2026
   					for (int il = 0; il < lams.length; il++) {                             // By Claude on 06/12/2026
   						lam_titles[il] = "lam="+lams[il];                                  // By Claude on 06/12/2026
   						for (int n5d = 0; n5d < nsc_sweep; n5d++) {                        // By Claude on 06/13/2026
   							score_lam[il][n5d] = cuasRTUtils.scoreC5PROI(                  // By Claude on 06/12/2026
   									c5p_raw[n0_sweep + n5d], lams[il], log_prior,          // By Claude on 06/13/2026
   									clt_parameters.imp.curt_c5_post_exact);                // By Claude on 06/13/2026
   							post_lam[il][n5d]  = cuasRTUtils.softmaxC5PROI(                // By Claude on 06/12/2026
   									score_lam[il][n5d], curt_save_select, null_score);     // By Claude on 06/12/2026
   						}                                                                  // By Claude on 06/12/2026
   						System.out.println(now()+" detectTargets(): C5P posterior lambda="+lams[il]+" done ("+(il+1)+"/"+lams.length+")"); // By Claude on 06/13/2026
   					}                                                                      // By Claude on 06/12/2026
   					String post_sfx = "-NZ"+null_z;                                        // By Claude on 06/12/2026
   					ImagePlus imp_score = cuasRTUtils.showC5PLambdaHyper(score_lam,        // By Claude on 06/12/2026
   							curt_save_select, ts_sweep, lam_titles,                        // By Claude on 06/13/2026
   							title_c5p+post_sfx+"-SCORE", false,                            // By Claude on 06/12/2026
   							new double [] {0.0, 4*null_score});                            // By Claude on 06/12/2026
   					QuadCLTCPU.saveImagePlusInDirectory(imp_score, getModelDirectory());   // By Claude on 06/12/2026
   					ImagePlus imp_post = cuasRTUtils.showC5PLambdaHyper(post_lam,          // By Claude on 06/12/2026
   							curt_save_select, ts_sweep, lam_titles,                        // By Claude on 06/13/2026
   							title_c5p+post_sfx+"-POST", false,                             // By Claude on 06/12/2026
   							new double [] {0.0, 1.0});                                     // By Claude on 06/12/2026
   					QuadCLTCPU.saveImagePlusInDirectory(imp_post, getModelDirectory());    // By Claude on 06/12/2026
   					ImagePlus imp_marg = cuasRTUtils.showC5PLambdaHyper(post_lam,          // By Claude on 06/12/2026
   							curt_save_select, ts_sweep, lam_titles,                        // By Claude on 06/13/2026
   							title_c5p+post_sfx+"-PMARG", true,                             // By Claude on 06/12/2026
   							new double [] {0.0, 1.0});                                     // By Claude on 06/12/2026
   					QuadCLTCPU.saveImagePlusInDirectory(imp_marg, getModelDirectory());    // By Claude on 06/12/2026
   					if (clt_parameters.imp.curt_save_c5hyper) {                            // per-velocity posterior view, one image per lambda // By Claude on 06/13/2026
   						// (the lambda-sweep hyperstack already uses both sliders for lambda x time, so velocity gets its own images) // By Claude on 06/13/2026
   						for (int il = 0; il < lams.length; il++) {                         // By Claude on 06/13/2026
   							ImagePlus imp_post_hyper = cuasRTUtils.showConvKernel5dHyperRect( // By Claude on 06/13/2026
   									post_lam[il], curt_save_select, ts_sweep,              // By Claude on 06/13/2026
   									title_c5p+post_sfx+"-POST-LAM"+lams[il]+"-HYPER-RECT"); // By Claude on 06/13/2026
   							imp_post_hyper.setDisplayRange(0.0, 1.0);                      // posterior in [0,1] // By Claude on 06/13/2026
   							QuadCLTCPU.saveImagePlusInDirectory(imp_post_hyper, getModelDirectory()); // By Claude on 06/13/2026
   						}                                                                  // By Claude on 06/13/2026
   					}                                                                      // By Claude on 06/13/2026
   				}                                                                          // By Claude on 06/12/2026
   				if (run_recur) {                                                           // By Claude on 06/11/2026
   					runRecurrentLevel(                                                     // By Claude on 06/11/2026
   							cuasRTUtils,      // CuasRTUtils     cuasRTUtils,              // By Claude on 06/11/2026
   							c5p_roi,          // double [][][][] conv5d_roi,               // By Claude on 06/11/2026
   							ts_5dp,           // String []       ts_5d,                    // By Claude on 06/11/2026
   							curt_save_select, // Rectangle       roi,                      // By Claude on 06/11/2026
   							title_c5p,        // String          title_base,               // By Claude on 06/11/2026
   							false);           // boolean         save_individual           // By Claude on 06/11/2026
   				}                                                                          // By Claude on 06/11/2026
   			}                                                                              // By Claude on 06/11/2026
   		}                                                                                  // By Claude on 06/11/2026
   		// DNN front-end (-DNN): if curt_dnn_model is set, run the trained FCN per-pixel       // By Claude on 06/13/2026
   		// (stride-1, matching training) over the ROI -> (Vx,Vy,s) field = softmax(vel)*s,
   		// same shape as the C5P output, saved as -DNN-RECT / -DNN-HYPER-RECT. N (temporal
   		// depth, 8 or 9) is read from the loaded ONNX. Reuses curt_c5_levels for level gating. Recurrent feed is the next step
   		// (the field is [0,1]-scaled, unlike the C5P matched-filter response - needs rescale).
   		if (!clt_parameters.imp.curt_dnn_model.isEmpty() && (curt_save_select != null)) {     // By Claude on 06/13/2026
   			final int vr_dnn = clt_parameters.imp.curt_vel_radius;                            // By Claude on 06/13/2026
   			final int dnn_stride = Math.max(1, clt_parameters.imp.curt_dnn_stride);           // 1 = every slice (testing), 4 = production 50% overlap // By Claude on 06/14/2026
   			try {                                                                             // By Claude on 06/13/2026
   				CuasDnnInfer dnn = new CuasDnnInfer(clt_parameters.imp.curt_dnn_model, 8);    // 8 = fallback hint only // By Claude on 06/13/2026
   				dnn.setPatch(clt_parameters.imp.curt_dnn_patch);                              // 24 or 32 (RF/training patch) - must match the loaded model // By Claude on 06/16/2026
   				final int N_dnn = dnn.getNFrames();                                           // 8 or 9, read from the loaded ONNX input shape -> 8/9 models swap at runtime // By Claude on 06/15/2026
   				for (int nlev = 0; nlev < pyramid_levels; nlev++) {                           // By Claude on 06/13/2026
   					if (!c5LevelSelected(c5_levels, nlev)) continue;                          // level gating (curt_c5_levels) // By Claude on 06/13/2026
   					double [][] framesD = dpixels_pyramid[nlev];                              // By Claude on 06/13/2026
   					if (framesD == null) continue;                                            // By Claude on 06/13/2026
   					// stride-4 (production) outputs every 4th level-slice (0..7, 4..11, ...); stride-1 (testing) every slice // By Claude on 06/14/2026
   					int num_all = (framesD.length >= N_dnn) ? ((framesD.length - N_dnn)/dnn_stride + 1) : 0; // By Claude on 06/14/2026
   					if (num_all == 0) continue;                                               // By Claude on 06/13/2026
   					// timing ROI (curt_time_from/to): infer ONLY the in-window scenes (compute-window, not just save) // By Claude on 06/14/2026
   					String [] cand_ts = new String [num_all];                                 // By Claude on 06/14/2026
   					for (int k = 0; k < num_all; k++) cand_ts[k] = ts_pyramid[nlev][k * dnn_stride + N_dnn - 1]; // By Claude on 06/14/2026
   					int [] tw = timeWindow(cand_ts); int w0 = tw[0], num = tw[1] - tw[0];     // By Claude on 06/14/2026
   					if (num <= 0) continue;                                                   // By Claude on 06/14/2026
   					double dnn_thresh = clt_parameters.imp.curt_dnn_thresh;                   // By Claude on 06/13/2026
   					if (dnn.isReg()) { // continuous-velocity (reg) head: save {S,Vx,Vy,sigma} + offset hyperstacks; no grid/ghostbuster/recurrent. By Claude on 06/17/2026
   						String title_reg = title_conv5d+"-DNN"+((nlev > 0)?("-LEV"+nlev):"")+"-ROI"+curt_save_select.x+"_"+curt_save_select.y+"_"+curt_save_select.width+"_"+curt_save_select.height;
   						int rnpr = curt_save_select.width * curt_save_select.height;
   						double [][][] svxys = new double [4][num][rnpr];   // [S,Vx,Vy,sigma][scene][pix] - S first (ImageJ auto-ranges from slice 1)
   						double [][][] soffr = new double [3][num][rnpr];   // [dx,dy,s]
   						String [] ts_reg = new String [num];
   						for (int j = 0; j < num; j++) {
   							int n5d = w0 + j, newest = n5d * dnn_stride + N_dnn - 1;
   							float [][] window = new float [N_dnn][];
   							for (int h = 0; h < N_dnn; h++) {
   								double [] src = framesD[newest - h]; float [] fw = new float [src.length];
   								for (int k = 0; k < src.length; k++) fw[k] = (float) src[k];
   								window[h] = fw;
   							}
   							double [][] o6 = dnn.inferROIReg(window, width, height, curt_save_select); // [pix]{S,Vx,Vy,sigma,dx,dy}
   							for (int p = 0; p < rnpr; p++) {
   								double sv = o6[p][0];
   								svxys[0][j][p] = sv; svxys[1][j][p] = o6[p][1]; svxys[2][j][p] = o6[p][2]; svxys[3][j][p] = o6[p][3];
   								soffr[0][j][p] = (sv >= dnn_thresh) ? o6[p][4] : Double.NaN;
   								soffr[1][j][p] = (sv >= dnn_thresh) ? o6[p][5] : Double.NaN;
   								soffr[2][j][p] = sv;
   							}
   							ts_reg[j] = ts_pyramid[nlev][newest] + " f"+newest;
   							System.out.println(now()+"   DNN-reg scene "+(j+1)+"/"+num+" (frame "+newest+") done");
   						}
   						QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(ShowDoubleFloatArrays.showArraysHyperstack(
   								svxys, curt_save_select.width, title_reg+"-VXYS", ts_reg, new String[]{"S","Vx","Vy","sigma"}, false), clt_parameters.imp), getModelDirectory());
   						QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(ShowDoubleFloatArrays.showArraysHyperstack(
   								soffr, curt_save_select.width, title_reg+"-OFFSET", ts_reg, new String[]{"dx","dy","s"}, false), clt_parameters.imp), getModelDirectory());
   						continue;  // reg level done - skip the grid path below
   					}
   					double [][][][] dnn_roi = new double [num][][][];                         // By Claude on 06/13/2026
   					double [][][]   dnn_off = new double [num][][];                            // per scene: [roi_pix][{dx,dy,s}] - sub-pixel offset viz // By Claude on 06/14/2026
   					String [] ts_dnn = new String [num];                                      // By Claude on 06/13/2026
   					System.out.println(now()+" detectTargets(): DNN inferROI LEV"+nlev+" ("+num+" of "+num_all+" scenes, stride "+dnn_stride+", "+ // By Claude on 06/14/2026
   							(curt_save_select.width*curt_save_select.height)+" px/scene, thresh="+dnn_thresh+")"); // By Claude on 06/13/2026
   					for (int j = 0; j < num; j++) {                                           // By Claude on 06/14/2026
   						int n5d = w0 + j;                                                     // in-window scene index // By Claude on 06/14/2026
   						int newest = n5d * dnn_stride + N_dnn - 1;                            // absolute newest-frame index in framesD (strided) // By Claude on 06/14/2026
   						float [][] window = new float [N_dnn][];                              // By Claude on 06/13/2026
   						for (int h = 0; h < N_dnn; h++) {                                     // By Claude on 06/13/2026
   							double [] src = framesD[newest - h];                              // h=0 newest // By Claude on 06/13/2026
   							float [] fw = new float [src.length];                             // By Claude on 06/13/2026
   							for (int k = 0; k < src.length; k++) fw[k] = (float) src[k];      // By Claude on 06/13/2026
   							window[h] = fw;                                                   // By Claude on 06/13/2026
   						}                                                                     // By Claude on 06/13/2026
   						double [][] off = new double [curt_save_select.width * curt_save_select.height][]; // {dx,dy,s} per ROI pixel // By Claude on 06/14/2026
   						dnn_roi[j] = dnn.inferROI(window, width, height, curt_save_select, vr_dnn, dnn_thresh, off); // By Claude on 06/14/2026
   						dnn_off[j] = off;                                                     // By Claude on 06/14/2026
   						ts_dnn[j] = ts_pyramid[nlev][newest] + " f"+newest;                   // timestamp + abs frame index for matching // By Claude on 06/14/2026
   						System.out.println(now()+"   DNN scene "+(j+1)+"/"+num+" (frame "+newest+", "+ts_pyramid[nlev][newest]+") done"); // By Claude on 06/14/2026
   					}                                                                         // By Claude on 06/13/2026
   					// Ghostbuster: drop untrained corner velocities (cell-R > vmax*vel_decimate) before save // By Claude on 06/15/2026
   					// + recurrent feed, so the spurious corner sidelobes don't confuse the recurrent (s=0 for ghost-peak pixels)
   					if (clt_parameters.imp.curt_dnn_vmax > 0) {                                // By Claude on 06/15/2026
   						double rmax_cells = clt_parameters.imp.curt_dnn_vmax * clt_parameters.imp.curt_vel_decimate;
   						for (int j = 0; j < num; j++) dnnGhostbust(dnn_roi[j], dnn_off[j], vr_dnn, rmax_cells);
   					}
   					// ROI tag in the filename (x_y_w_h) so the python flight-log analysis auto-restores the ROI - no manual entry. By Claude on 06/16/2026
   					String roiTag = "-ROI"+curt_save_select.x+"_"+curt_save_select.y+"_"+curt_save_select.width+"_"+curt_save_select.height;
   					String title_dnn = title_conv5d+"-DNN"+((nlev > 0)?("-LEV"+nlev):"")+roiTag; // By Claude on 06/13/2026
   					int [] win_dnn = timeWindow(ts_dnn); // timing ROI // By Claude on 06/14/2026
   					double [][][][] dnn_w = win4(dnn_roi, win_dnn); String [] ts_dnn_w = winS(ts_dnn, win_dnn); // By Claude on 06/14/2026
   					if (curt_save_c5rect) {                                                   // By Claude on 06/13/2026
   						QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(cuasRTUtils.showConvKernel5d( // By Claude on 06/13/2026
   								dnn_w, curt_save_select, ts_dnn_w, title_dnn+"-RECT"), clt_parameters.imp), getModelDirectory()); // metadata: all curt_* // By Claude on 06/16/2026
   					}                                                                         // By Claude on 06/13/2026
   					if (clt_parameters.imp.curt_save_c5hyper) {                               // By Claude on 06/13/2026
   						QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(cuasRTUtils.showConvKernel5dHyperRect( // By Claude on 06/13/2026
   								dnn_w, curt_save_select, ts_dnn_w, title_dnn+"-HYPER-RECT"), clt_parameters.imp), getModelDirectory()); // metadata: all curt_* // By Claude on 06/16/2026
   					}                                                                         // By Claude on 06/13/2026
   					// -DNN-OFFSET: the per-pixel sub-pixel offset (dx,dy) + confidence s over the ROI,     // By Claude on 06/14/2026
   					// to see whether a cluster's offsets converge on one fractional position. Channels
   					// (bottom slider): 0=dx, 1=dy (NaN where s<0.05 so only detections show), 2=s.
   					{
   						int rwid = curt_save_select.width, rhgt = curt_save_select.height, rnp = rwid * rhgt;
   						int nsc = win_dnn[1] - win_dnn[0];
   						double [][][] off_hyper = new double [3][nsc][rnp];
   						double vthr = 0.05;
   						for (int k = 0; k < nsc; k++) {
   							double [][] off = dnn_off[win_dnn[0] + k];
   							for (int p = 0; p < rnp; p++) {
   								double dx = Double.NaN, dy = Double.NaN, sv = 0.0;
   								if ((off != null) && (off[p] != null)) { sv = off[p][2]; if (sv >= vthr) { dx = off[p][0]; dy = off[p][1]; } }
   								off_hyper[0][k][p] = dx; off_hyper[1][k][p] = dy; off_hyper[2][k][p] = sv;
   							}
   						}
   						QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(ShowDoubleFloatArrays.showArraysHyperstack(
   								off_hyper, rwid, title_dnn+"-OFFSET", ts_dnn_w, new String[]{"dx","dy","s"}, false), clt_parameters.imp), getModelDirectory()); // metadata: all curt_* // By Claude on 06/16/2026
   					}
   					// Feed the DNN field to the recurrent layer (Layer 2): as-is, or offset-splatted (px+dx,py+dy). // By Claude on 06/14/2026
   					// Tune the recurrent blend with curt_recur_w (0.25/0.75) etc.; the field is [0,1]-scaled so rs_min may need lowering.
   					if (run_recur && (num > 0)) {
   						boolean splat = clt_parameters.imp.curt_dnn_recur_splat;
   						double  rscale = clt_parameters.imp.curt_dnn_recur_scale;            // DNN field ~0.1 -> recurrent rs_min ~1.0 // By Claude on 06/14/2026
   						double [][][][] dnn_feed = new double [num][][][];
   						for (int j = 0; j < num; j++) {
   							double [][][] f = splat ? splatField(dnn_roi[j], dnn_off[j], curt_save_select) : dnn_roi[j];
   							if (rscale != 1.0) {
   								double [][][] g = new double [f.length][][];
   								for (int p = 0; p < f.length; p++) {
   									g[p] = new double [f[p].length][];
   									for (int su = 0; su < f[p].length; su++) { g[p][su] = f[p][su].clone(); for (int v = 0; v < g[p][su].length; v++) g[p][su][v] *= rscale; }
   								}
   								f = g;
   							}
   							dnn_feed[j] = f;
   						}
   						String rtitle = title_dnn + (splat ? "-SPLAT" : "");
   						System.out.println(now()+" detectTargets(): DNN recurrent feed LEV"+nlev+(splat?" (offset-splat)":" (as-is)")+", scale "+rscale); // By Claude on 06/14/2026
   						runRecurrentLevel(cuasRTUtils, dnn_feed, ts_dnn, curt_save_select, rtitle, false);
   					}
   				}                                                                             // By Claude on 06/13/2026
   				dnn.close();                                                                  // By Claude on 06/13/2026
   			} catch (Exception e) {                                                           // By Claude on 06/13/2026
   				System.out.println("DNN inference failed: "+e); e.printStackTrace();          // By Claude on 06/13/2026
   			}                                                                                 // By Claude on 06/13/2026
   		}                                                                                     // By Claude on 06/13/2026
   		System.out.println(now()+" detectTargets(): done"); // By Claude on 06/11/2026
   		return null;
   		/*
		CuasMotion cuasMotion = new CuasMotion (
				clt_parameters,              // CLTParameters     clt_parameters,
				getTimeStamps(),                // String []         scene_titles,
				cuasRanging.getCenter_CLT(), // QuadCLT           parentCLT,
				null, // fpixels_avg,                 // float []          fpixels_avg,
				uasLogReader,                // UasLogReader      uasLogReader,
				debugLevel);                 // int               debugLevel)
		return cuasMotion;
		*/
	}
}
/*
					uasLogReader,   // UasLogReader    uasLogReader,
batch_mode);    // boolean         batch_mode)

*/