Commit 4383bd62 authored by vertighel's avatar vertighel
Browse files

feat: explore profile chart + Gaussian stats (xc/yc/fwhm/amp/bkg)



Adds cv-profile canvas and explore-stats aside to _mini(); new
explore-stats.js draws column/row projection profiles (info/warning
colors) and estimates centroid, FWHM, peak and background analytically
from the tile float32 data. Also fixes centerIndex in
_renderExploreTile (was pointing to row-start instead of tile centre).

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent edc3a13d
Loading
Loading
Loading
Loading
Loading
+24 −9
Original line number Diff line number Diff line
@@ -25,7 +25,7 @@
#}

{% macro _canvas() %}
<div class="viewer-canvas-wrap position-relative bg-black overflow-hidden rounded">
<figure class="viewer-canvas-wrap position-relative bg-black overflow-hidden rounded">
    <canvas class="cv-main d-block w-100" width="800" height="800"
            style="cursor:crosshair; transform-origin:0 0;"></canvas>
    {#- cv-overlay: transparent 2-D canvas for annotations (crosshairs, ROI, etc.)
@@ -44,30 +44,45 @@
        <span class="badge bg-black bg-opacity-75 info-main font-monospace"
              style="font-size:.65rem;"></span>
    </div>
</div>
</figure>
{% endmacro %}


{% macro _mini(mini_col, panel_id) %}
<div class="row g-1">
    <div class="{{ mini_col }} bg-black rounded-1 overflow-hidden">
    <figure class="{{ mini_col }} bg-black rounded-1 overflow-hidden">
        <canvas class="cv-panoramic d-block w-100 h-auto" width="256" height="256"></canvas>
    </figure>
    <figure class="{{ mini_col }} bg-black rounded-1 overflow-hidden">
        <canvas class="cv-explore d-block w-100" width="256" height="256"
                style="image-rendering:pixelated;"></canvas>
    </div>
    <div class="{{ mini_col }} bg-black rounded-1 overflow-hidden">
        <canvas class="cv-panoramic d-block w-100 h-auto" width="256" height="256"></canvas>
    </div>
    </figure>
    <div class="col-12 mt-1">
        <div class="input-group input-group-sm">
            <label class="input-group-text" for="ctrl-explore-w-{{ panel_id }}"
                   style="font-size:.65rem; padding:2px 6px;
                          background:#1a1a1a; border-color:#333; color:#aaa;">Box</label>
                   style="font-size:.65rem; padding:2px 6px;">Box</label>
            <input type="number" class="ctrl-explore-w form-control"
                   id="ctrl-explore-w-{{ panel_id }}"
                   value="32" step="8"
                   style="background:#111; border-color:#333; font-size:.7rem; color:#b0ffb0;">
            <span class="input-group-text" style="font-size:.65rem; padding:2px 6px;">px</label>
        </div>
    </div>
    <figure class="col-12 bg-black rounded-1 overflow-hidden">
        <canvas class="cv-profile d-block w-100" width="256" height="160"></canvas>
    </figure>
    <aside class="explore-stats col-12 mt-1 font-monospace" style="font-size:.65rem;">
        <div class="d-flex gap-3">
            <span class="text-muted">xc: <var class="stat-xc text-light"></var></span>
            <span class="text-muted">xfwhm: <var class="stat-xfwhm text-light"></var></span>
            <span class="text-muted">amp: <var class="stat-amp text-light"></var></span>
        </div>
        <div class="d-flex gap-3">
            <span class="text-muted">yc: <var class="stat-yc text-light"></var></span>
            <span class="text-muted">yfwhm: <var class="stat-yfwhm text-light"></var></span>
            <span class="text-muted">bkg: <var class="stat-bkg text-light"></var></span>
        </div>
    </aside>
</div>
{% endmacro %}

+154 −0
Original line number Diff line number Diff line
// noctua/web/static/js/viewer/explore-stats.js
//
// Profile chart and Gaussian parameter estimator for the explore tile.
// Called by FitsViewer after each tile render; has no dependency on
// fits-viewer.js internals (receives the Float32Array and tile dimensions).

const COLOR_X = '#0dcaf0';  // Bootstrap info  (profile along columns)
const COLOR_Y = '#ffc107';  // Bootstrap warning (profile along rows)
const PAD     = 4;          // inner padding in canvas px


export class ExploreStats {
    /**
     * Parameters
     * ----------
     * canvasEl : HTMLCanvasElement  — the cv-profile canvas
     * asideEl  : HTMLElement        — the .explore-stats aside
     */
    constructor(canvasEl, asideEl) {
        this._canvas = canvasEl;
        this._ctx    = canvasEl?.getContext('2d') ?? null;

        this._vars = asideEl ? {
            xc:    asideEl.querySelector('.stat-xc'),
            xfwhm: asideEl.querySelector('.stat-xfwhm'),
            amp:   asideEl.querySelector('.stat-amp'),
            yc:    asideEl.querySelector('.stat-yc'),
            yfwhm: asideEl.querySelector('.stat-yfwhm'),
            bkg:   asideEl.querySelector('.stat-bkg'),
        } : {};
    }

    /**
     * Recompute profiles from a new tile and refresh the canvas + aside.
     *
     * Parameters
     * ----------
     * float32Data : Float32Array  row-major pixel values, shape h × w
     * w, h        : number        tile dimensions in image pixels
     * cx, cy      : number        image coordinates of the tile centre
     */
    update(float32Data, w, h, cx, cy) {
        const profileX = new Float64Array(w);   // sum over rows  → one value per column
        const profileY = new Float64Array(h);   // sum over cols  → one value per row

        for (let r = 0; r < h; r++) {
            for (let c = 0; c < w; c++) {
                const v = float32Data[r * w + c];
                profileX[c] += v;
                profileY[r] += v;
            }
        }

        const stX = this._estimate(profileX);
        const stY = this._estimate(profileY);

        // Peak pixel value in the tile (box-size independent).
        let peak = -Infinity;
        for (let i = 0; i < float32Data.length; i++)
            if (float32Data[i] > peak) peak = float32Data[i];

        // Convert tile-relative centroid to image coordinates.
        const halfW = (w - 1) / 2;
        const halfH = (h - 1) / 2;

        this._draw(profileX, profileY);

        this._set('xc',    (cx - halfW + stX.centroid).toFixed(1));
        this._set('xfwhm', stX.fwhm.toFixed(1));
        this._set('amp',   peak.toFixed(0));
        this._set('yc',    (cy - halfH + stY.centroid).toFixed(1));
        this._set('yfwhm', stY.fwhm.toFixed(1));
        this._set('bkg',   (stY.bg  / w).toFixed(0));
    }


    // ── private ──────────────────────────────────────────────────────────────

    /** Analytical Gaussian estimator on a 1-D profile array. */
    _estimate(profile) {
        const n   = profile.length;
        const bg  = Math.min(...profile);
        const amp = Math.max(...profile) - bg;

        // Weighted centroid.
        let sumW = 0, sumWI = 0;
        for (let i = 0; i < n; i++) {
            const w = Math.max(0, profile[i] - bg);
            sumW  += w;
            sumWI += w * i;
        }
        const centroid = sumW > 0 ? sumWI / sumW : (n - 1) / 2;

        // FWHM via linear interpolation of the two half-max crossings.
        const half = bg + amp / 2;
        let left  = 0;
        let right = n - 1;
        for (let i = 0; i < n - 1; i++) {
            if (profile[i] <= half && profile[i + 1] > half)
                left  = i + (half - profile[i]) / (profile[i + 1] - profile[i]);
            if (profile[i] >= half && profile[i + 1] < half)
                right = i + (profile[i] - half) / (profile[i] - profile[i + 1]);
        }
        const fwhm = Math.max(0, right - left);

        return { bg, amp, centroid, fwhm };
    }

    /** Draw both profiles on the canvas sharing the same y scale. */
    _draw(profileX, profileY) {
        const cv  = this._canvas;
        const ctx = this._ctx;
        if (!cv || !ctx) return;

        const W = cv.width;
        const H = cv.height;

        ctx.fillStyle = '#000';
        ctx.fillRect(0, 0, W, H);

        // Common y range across both profiles.
        let yMin =  Infinity;
        let yMax = -Infinity;
        for (const p of [profileX, profileY]) {
            for (const v of p) {
                if (v < yMin) yMin = v;
                if (v > yMax) yMax = v;
            }
        }
        const yRange = yMax - yMin || 1;
        const toY = v => PAD + (1 - (v - yMin) / yRange) * (H - 2 * PAD);

        this._drawCurve(ctx, profileX, W, toY, COLOR_X);
        this._drawCurve(ctx, profileY, W, toY, COLOR_Y);
    }

    _drawCurve(ctx, profile, canvasW, toY, color) {
        const n = profile.length;
        if (n < 2) return;
        ctx.beginPath();
        ctx.strokeStyle = color;
        ctx.lineWidth   = 1;
        for (let i = 0; i < n; i++) {
            const x = PAD + (i / (n - 1)) * (canvasW - 2 * PAD);
            i === 0 ? ctx.moveTo(x, toY(profile[i]))
                    : ctx.lineTo(x, toY(profile[i]));
        }
        ctx.stroke();
    }

    _set(key, val) {
        if (this._vars[key]) this._vars[key].textContent = val;
    }
}
+14 −3
Original line number Diff line number Diff line
@@ -36,6 +36,7 @@

import { GLRenderer }   from './gl-renderer.js';
import { COLORMAPS }    from './colormaps.js';
import { ExploreStats } from './explore-stats.js';

// ---------------------------------------------------------------------------
// Module-level constants
@@ -98,6 +99,8 @@ export class FitsViewer {
            canvasOverlay: root.querySelector('.cv-overlay'),
            canvasPano:    root.querySelector('.cv-panoramic'),
            canvasExplore: root.querySelector('.cv-explore'),
            canvasProfile: root.querySelector('.cv-profile'),
            exploreStats:  root.querySelector('.explore-stats'),
            inputVmin:     root.querySelector('.ctrl-vmin'),
            inputVmax:     root.querySelector('.ctrl-vmax'),
            chkAuto:       root.querySelector('#ctrl-auto-primary'),
@@ -167,6 +170,12 @@ export class FitsViewer {
        // ── Explore GLRenderer ─────────────────────────────────────────────
        this._exploreGL = null;  // GLRenderer for the explore (magnifier) canvas

        // ── Explore profile chart ──────────────────────────────────────────
        this._exploreStats = new ExploreStats(
            this._el.canvasProfile,
            this._el.exploreStats,
        );

        this._bindControls();
        this._bindCanvasEvents();
        this._listenStream();
@@ -748,9 +757,11 @@ export class FitsViewer {
        this._exploreGL.render(true);

        // Display the pixel value at the centre of the tile.
        const centerIndex = Math.floor(float32Data.length / 2);
        const centerIndex = Math.floor(tileHeight / 2) * tileWidth + Math.floor(tileWidth / 2);
        const pixelValue  = float32Data[centerIndex];
        this._updatePixelInfo(imageX, imageY, pixelValue);

        this._exploreStats.update(float32Data, tileWidth, tileHeight, imageX, imageY);
    }