/**
 * LocalCNNService — On-device mould detection pipeline.
 *
 * Merges Config + Models + Analysis + Species from the reference hybrid app
 * into a single ES5 IIFE for Babel 6 compatibility.
 *
 * Two neural networks run in sequence:
 *   1. Binary detector  — Is there mould? (86.64% accuracy, MobileNet v1 backbone)
 *   2. Species classifier — What type? (78.53% accuracy, 7-class softmax)
 *
 * Gradient Saliency Heatmap always computed after binary detection.
 * Saliency Concentration Score feeds back into the final confidence value,
 * enabling detection of small localised mould spots.
 *
 * Public API:
 *   LocalCNNService.load(onProgress) → Promise — loads TF.js models
 *   LocalCNNService.isReady()        → boolean
 *   LocalCNNService.analyze(imgElement, onStage) → Promise<result>
 *
 * analyze() result shape (superset of MouldDetectService.analyze()):
 *   {
 *     percentage, verdict, severity, label, insight,  ← Nyckel-compatible fields
 *     aiEngine,                                        ← 'local_cnn'
 *     rawScore,                                        ← raw model output [0–100]
 *     freqSpectrum,                                    ← { low, mid, high, signature }
 *     saliencyLevel,                                   ← 'HIGH' | 'MODERATE' | 'DIFFUSE'
 *     saliencyPeakToMean,                              ← peak-to-mean ratio
 *     isShortcut,                                      ← border attention warning
 *     heatmapDataURL,                                  ← base64 JPEG of overlay canvas
 *     speciesTop,                                      ← { label, confidence, description, color } | null
 *     speciesRanked,                                   ← [{ label, confidence, color, description }]
 *     speciesIsUncertain,                              ← boolean
 *     speciesSummary,                                  ← plain-English string
 *   }
 *
 * Depends on: GradCAM (window.GradCAM), TF.js (tf global), MobileNet CDN
 * Feature flag: only initialises when AI_ENGINE is 'local' or 'hybrid'
 */
var LocalCNNService = (function () {

    // ── Config ────────────────────────────────────────────────────────────────

    var IMAGE_SIZE = 224;

    // Cache-bust VERSION strings — appended as ?v=VERSION to all model file URLs.
    // Update on each model deployment to force browsers/CDNs to fetch new weights.
    // Format: YYYY-MM-DD-accuracy. See DR-010.
    var BINARY_MODEL = {
        JSON:     'models/binary/mould-classifier-v10-model.json',
        METADATA: 'models/binary/mould-classifier-v10-model-metadata.json',
        VERSION:  '2026-04-26-83.82'
    };

    var SPECIES_MODEL = {
        JSON:     'models/species/mould-species-classifier.json',
        METADATA: 'models/species/mould-species-classifier-metadata.json',
        VERSION:  '2026-04-25-81.55'
    };

    var SPECIES_LABELS = ['Alternaria', 'Asp. Flavi', 'Asp. Nigri', 'Fusarium', 'Penicillium', 'Rhizopus', 'No Mould'];

    var SPECIES_COLORS = ['#8b5cf6', '#f59e0b', '#10b981', '#ef4444', '#3b82f6', '#ec4899', '#64748b'];

    var SPECIES_DESCRIPTIONS = [
        'Dark-pigmented mould commonly found on damp building materials and around window frames.',
        'Yellow-green mould often found in warm, humid environments. Can produce aflatoxins.',
        'Black-spored mould frequently found on damp walls, food, and organic matter.',
        'Pink or salmon-coloured mould associated with water damage and high moisture.',
        'Blue-green powdery mould common on food, wallpaper, and damp fabrics.',
        'Grey-black cottony mould that grows rapidly on bread, fruit, and damp surfaces.'
    ];

    var NO_MOULD_INDEX = 6;
    var NUM_SPECIES = 7;
    var BINARY_THRESHOLD = 0.5;
    var SPECIES_MIN_CONFIDENCE = 10;
    var MIN_BINARY_WEIGHT = 0.3; // M8: at binary=0.5 → weight=0.3; at binary=1.0 → weight=1.0
    var BORDER_RATIO = 0.15;
    var BORDER_THRESHOLD = 0.55;
    var HEATMAP_JPEG_QUALITY = 0.7;

    var VARIANCE_THRESHOLDS = { VERY_LOW: 0.001, LOW: 0.005, MEDIUM: 0.02 };

    var TEXTURE_SIGNATURES = {
        MOULD_MID_MIN: 0.25,
        // Lowered 0.10 → 0.08 (DR-008): browser colour management on <img> elements
        // reduces measured high-frequency energy vs raw file read. Vault image scores
        // High ~9% in app vs ~13% in binary trainer. 0.08 gives 1pp margin.
        // Raised 0.08 → 0.12 (DR-011): interior rooms with mid-range texture
        // (low=0.60-0.63) satisfy mid>=0.25 but high-freq energy sits 0.09-0.11.
        // Raising this gate reclassifies them TEXTURED->MIXED (x0.85). See Python DR-011.
        MOULD_HIGH_MIN: 0.12,
        UNIFORM_LOW_DOMINANT: 0.80,
        // Lowered 0.65 → 0.58 (DR-011): the 3 FP interior rooms have low-freq
        // energy 0.605-0.631. Lowering captures them into NEAR_UNIFORM (x0.5),
        // halving their raw=100% pass-through. Affected mould TPs have raw=0%
        // so factor change has no impact on them. See Python DR-011.
        NEAR_UNIFORM_LOW_DOMINANT: 0.58,
        NOISE_HIGH_DOMINANT: 0.50
    };

    var SALIENCY = {
        HIGH_CONCENTRATION: 8.0,
        MODERATE_CONCENTRATION: 4.0,
        MAX_BOOST: 2.0,
        // New (DR-005): suppress localised-spot uplift on bright surfaces.
        // Mould colonies are typically darker than background; wet paint and
        // specular highlights commonly exceed this brightness value.
        BRIGHT_SURFACE_THRESHOLD: 0.82
    };

    // Patch-based analysis (H4): 3×3 overlapping patches, binary head on each.
    // Detects mould covering the whole frame at a scale the global model was not
    // trained on (e.g. dark stone vault walls). Only fires on dark surfaces.
    var PATCH = {
        GRID: 3,
        STRIDE_RATIO: 0.5,
        PER_PATCH_THRESHOLD: 0.35,
        MIN_POSITIVE_FRACTION: 0.45,
        MAX_CONTRIBUTION: 0.72,
        PLAUSIBILITY_GATE: 0.10,
        DARK_SURFACE_MAX: 0.50
    };

    // Texture regularity (H5): variance-of-local-variances across 4×4 grid.
    var TEXTURE = {
        GRID: 4,
        HIGH_IRREGULARITY: 0.008,
        MODERATE_IRREGULARITY: 0.003
    };

    // Contextual dark-surface prior (H6): four-signal consensus gate.
    // All four signals must agree before detection threshold is lowered.
    // Prevents false positives on dark wallpaper, paint, wood, poorly lit rooms.
    var PRIOR = {
        DARK_SURFACE_THRESHOLD:     0.40,
        IRREGULARITY_THRESHOLD:     0.005,
        PLAUSIBILITY_GATE:          0.08,
        MAX_INTER_CHANNEL_DIFF:     0.06,
        MIN_DARK_SALIENCY_FRACTION: 0.40,
        DARK_PIXEL_THRESHOLD:       0.35,
        // Lowered 0.38 → 0.30 (DR-001): vault image rawScore ~0.305 after v14 training.
        // Revisit upward when more dark stone mould training images are added.
        DARK_MOULD_THRESHOLD:       0.30
    };

    var ENTROPY_MAX_7 = -Math.log(1 / NUM_SPECIES);
    var UNCERTAINTY_ENTROPY_THRESHOLD = ENTROPY_MAX_7 * 0.6;

    // ── State ─────────────────────────────────────────────────────────────────

    var _mobilenet = null;
    var _binaryHead = null;
    var _speciesHead = null;
    var _ready = false;

    // Load state machine: idle | loading | ready | failed
    // Exposed via getLoadState() so AnalysisPage and App.jsx can make
    // engine-selection decisions without coupling to the internal _ready flag.
    var _loadState = 'idle';
    var _loadError  = null;

    // Dispose all loaded model resources and reset to idle.
    // Called on load failure to prevent GPU memory leaks on retry.
    function _resetState() {
        try { if (_binaryHead)  { _binaryHead.dispose();  } } catch (e) {}
        try { if (_speciesHead) { _speciesHead.dispose(); } } catch (e) {}
        try { if (_mobilenet && typeof _mobilenet.dispose === 'function') { _mobilenet.dispose(); } } catch (e) {}
        _mobilenet   = null;
        _binaryHead  = null;
        _speciesHead = null;
        _ready       = false;
    }

    // ── Models ────────────────────────────────────────────────────────────────

    function _preprocessForMobileNet(tensor) {
        return tf.tidy(function () {
            var squeezed = tensor.squeeze();
            return tf.sub(tf.mul(squeezed, 2.0), 1.0);
        });
    }

    function _extractFeatures(tensor) {
        return tf.tidy(function () {
            var preprocessed = _preprocessForMobileNet(tensor);
            return _mobilenet.infer(preprocessed, true);
        });
    }

    function _imageToTensor(imgElement) {
        return tf.tidy(function () {
            return tf.browser.fromPixels(imgElement)
                .resizeNearestNeighbor([IMAGE_SIZE, IMAGE_SIZE])
                .toFloat()
                .div(255.0)
                .expandDims(0);
        });
    }

    // M7: Raw-file tensor path (DR-009).
    // FileReader → dataURL → new Image() → _imageToTensor bypasses the browser
    // colour management pipeline applied to already-displayed <img> elements,
    // producing pixel values consistent with the binary trainer. This ensures
    // frequency analysis (especially high-frequency energy) matches training.
    function _fileToTensor(file) {
        return new Promise(function (resolve, reject) {
            var reader = new FileReader();
            reader.onload = function (e) {
                var img = new Image();
                img.onload = function () {
                    try {
                        resolve(_imageToTensor(img));
                    } catch (err) {
                        reject(new Error('Failed to process image: ' + err.message));
                    } finally {
                        // MR-02 + LR-05 FIX: null handlers and clear src to release
                        // decoded pixel buffer (~48MB for 12MP photo)
                        img.onload = img.onerror = null;
                        img.src = '';
                    }
                };
                img.onerror = function () {
                    img.onload = img.onerror = null; // MR-02 FIX
                    reject(new Error('Failed to decode image file'));
                };
                var dataUrl = e.target.result;
                if (typeof dataUrl !== 'string' || dataUrl.indexOf('data:image/') !== 0) {
                    reject(new Error('Invalid image dataURL from FileReader'));
                    return;
                }
                img.src = dataUrl;
            };
            reader.onerror = function () { reject(new Error('Failed to read file')); };
            reader.readAsDataURL(file);
        });
    }

    function _predictBinary(features) {
        var prediction = tf.tidy(function () {
            return _binaryHead.predict(features.squeeze().expandDims(0));
        });
        return prediction.data().then(function (data) {
            prediction.dispose();
            return data[0];
        }, function (err) {
            // Dispose on the failure path too (WebGL context loss etc.)
            prediction.dispose();
            throw err;
        });
    }

    function _predictSpecies(features) {
        var prediction = tf.tidy(function () {
            return _speciesHead.predict(features.squeeze().expandDims(0));
        });
        return prediction.data().then(function (data) {
            prediction.dispose();
            return Array.from(data);
        }, function (err) {
            prediction.dispose();
            throw err;
        });
    }

    function _validate() {
        var input = tf.tidy(function () {
            var testTensor = tf.fill([1, IMAGE_SIZE, IMAGE_SIZE, 3], 128 / 255);
            var features = _extractFeatures(testTensor);
            return features.squeeze().expandDims(0);
        });
        var disposeInput = function () {
            if (!input.isDisposed) input.dispose();
        };
        var run = function () {
            var bPred = _binaryHead.predict(input);
            return bPred.data().then(function (bData) {
                bPred.dispose();
                var bScore = bData[0];
                if (isNaN(bScore) || bScore < 0 || bScore > 1) {
                    throw new Error('Binary model validation failed');
                }
                var sPred = _speciesHead.predict(input);
                return sPred.data().then(function (sData) {
                    sPred.dispose();
                    if (sData.length !== NUM_SPECIES) {
                        throw new Error('Species model validation failed — expected ' + NUM_SPECIES + ' outputs, got ' + sData.length);
                    }
                }, function (err) {
                    sPred.dispose();
                    throw err;
                });
            }, function (err) {
                bPred.dispose();
                throw err;
            });
        };
        var p;
        try {
            p = run();
        } catch (err) {
            // Synchronous throw from predict() — dispose before propagating.
            disposeInput();
            throw err;
        }
        return p.then(function (result) {
            disposeInput();
            return result;
        }, function (err) {
            disposeInput();
            throw err;
        });
    }

    // ── Analysis ──────────────────────────────────────────────────────────────

    function _analyzeVariance(tensor) {
        var V = VARIANCE_THRESHOLDS;
        return tf.tidy(function () {
            var squeezed = tensor.squeeze();
            return tf.moments(squeezed).variance;
        }).data().then(function (varianceVal) {
            var variance = varianceVal[0];
            if (variance < V.VERY_LOW) return { variance: variance, level: 'VERY_LOW', factor: 0.1, description: 'Completely uniform surface' };
            if (variance < V.LOW)      return { variance: variance, level: 'LOW',      factor: 0.5, description: 'Low texture surface' };
            if (variance < V.MEDIUM)   return { variance: variance, level: 'MEDIUM',   factor: 0.8, description: 'Medium texture surface' };
            return { variance: variance, level: 'HIGH', factor: 1.0, description: 'High texture surface' };
        });
    }

    function _analyzeFrequency(tensor) {
        var T = TEXTURE_SIGNATURES;
        var grayTensor = tf.tidy(function () { return tensor.squeeze().mean(2); });
        return grayTensor.data().then(function (data) {
            var h = grayTensor.shape[0];
            var w = grayTensor.shape[1];
            grayTensor.dispose();

            var scales = [
                { step: 16, band: 'low',  stride: 8 },
                { step: 4,  band: 'mid',  stride: 2 },
                // stride=1 for high band (DR-007): stride=2 under-samples high-frequency
                // energy, shifting vault images from TEXTURED (×1.0) to MIXED (×0.85).
                // The binary trainer uses stride=1 as the reference implementation.
                { step: 1,  band: 'high', stride: 1 }
            ];
            var energy = { low: 0, mid: 0, high: 0 };
            var total = 0;

            for (var si = 0; si < scales.length; si++) {
                var step = scales[si].step;
                var band = scales[si].band;
                var stride = scales[si].stride;
                var bandE = 0; var count = 0;
                for (var y = 0; y < h - step; y += stride) {
                    for (var x = 0; x < w - step; x += stride) {
                        var idx = y * w + x;
                        var dx = data[idx] - data[y * w + Math.min(x + step, w - 1)];
                        var dy = data[idx] - data[Math.min(y + step, h - 1) * w + x];
                        bandE += dx * dx + dy * dy;
                        count++;
                    }
                }
                var norm = count > 0 ? bandE / count : 0;
                energy[band] = norm;
                total += norm;
            }

            var eps = 1e-10;
            var low  = energy.low  / (total + eps);
            var mid  = energy.mid  / (total + eps);
            var high = energy.high / (total + eps);

            var signature, factor, description;
            if (total < eps) {
                signature = 'FLAT'; factor = 0.1; description = 'No frequency content';
            } else if (low > T.UNIFORM_LOW_DOMINANT) {
                signature = 'UNIFORM'; factor = 0.3; description = 'Low-frequency dominant (smooth surface)';
            } else if (low > T.NEAR_UNIFORM_LOW_DOMINANT) {
                // DR-004: closes false-positive gap between UNIFORM (×0.3) and MIXED (×0.85).
                signature = 'NEAR_UNIFORM'; factor = 0.5; description = 'Near-uniform surface (low texture, possible plain wall)';
            } else if (high > T.NOISE_HIGH_DOMINANT) {
                signature = 'NOISY'; factor = 0.5; description = 'High-frequency dominant (noise/grain)';
            } else if (mid >= T.MOULD_MID_MIN && high >= T.MOULD_HIGH_MIN) {
                signature = 'TEXTURED'; factor = 1.0; description = 'Multi-scale texture (possible mould)';
            } else {
                signature = 'MIXED'; factor = 0.85; description = 'Mixed frequency profile';
            }

            // Retain grayscale data for texture regularity (H5) and dark-region
            // saliency (H6 signal 4) — stripped from the public result in _analyze().
            return { low: low, mid: mid, high: high, total: total, signature: signature, factor: factor, description: description, _grayData: data, _h: h, _w: w };
        });
    }

    // ── New analysis functions (M4) ────────────────────────────────────────────────────────────────────────────────────

    // Mean image brightness [0,1] — used by brightness guard (DR-005) and H4/H6 gates.
    function _analyzeBrightness(tensor) {
        return tf.tidy(function () { return tf.mean(tensor); }).data().then(function (d) { return d[0]; });
    }

    // Colour saturation (H6 signal 3): mean absolute inter-channel difference.
    // Mould on stone is desaturated (R≈G≈B); coloured surfaces have a colour cast.
    function _analyzeColourSaturation(tensor) {
        return tf.tidy(function () {
            return tf.mean(tensor.squeeze(), [0, 1]);
        }).data().then(function (ch) {
            var r = ch[0]; var g = ch[1]; var b = ch[2];
            var avg = (r + g + b) / 3;
            var diff = (Math.abs(r - avg) + Math.abs(g - avg) + Math.abs(b - avg)) / 3;
            return { interChannelDiff: diff, isDesaturated: diff < PRIOR.MAX_INTER_CHANNEL_DIFF, channelMeans: { r: r, g: g, b: b } };
        });
    }

    // Texture regularity (H5): variance-of-local-variances across a 4×4 grid.
    // Clean surfaces have uniform texture (low irregularity).
    // Mould-covered surfaces have irregular texture (high irregularity).
    // Reuses grayscale data already in CPU memory from _analyzeFrequency.
    function _analyzeTextureRegularity(grayData, h, w) {
        var grid = TEXTURE.GRID;
        var winH = Math.floor(h / grid);
        var winW = Math.floor(w / grid);
        var localVariances = [];
        for (var row = 0; row < grid; row++) {
            for (var col = 0; col < grid; col++) {
                var y0 = row * winH; var x0 = col * winW;
                var sum = 0; var sumSq = 0; var count = 0;
                for (var y = y0; y < y0 + winH && y < h; y++) {
                    for (var x = x0; x < x0 + winW && x < w; x++) {
                        var v = grayData[y * w + x];
                        sum += v; sumSq += v * v; count++;
                    }
                }
                if (count > 0) {
                    var mean = sum / count;
                    localVariances.push(sumSq / count - mean * mean);
                }
            }
        }
        var n = localVariances.length;
        if (n === 0) return { irregularity: 0, level: 'UNKNOWN', localVariances: [] };
        var varMean = 0;
        for (var i = 0; i < n; i++) varMean += localVariances[i];
        varMean /= n;
        var irregularity = 0;
        for (var j = 0; j < n; j++) irregularity += (localVariances[j] - varMean) * (localVariances[j] - varMean);
        irregularity /= n;
        var level = irregularity >= TEXTURE.HIGH_IRREGULARITY ? 'HIGH'
                  : irregularity >= TEXTURE.MODERATE_IRREGULARITY ? 'MODERATE' : 'LOW';
        return { irregularity: irregularity, level: level, localVariances: localVariances };
    }

    // Dark-region saliency fraction (H6 signal 4).
    // Fraction of top-20% heatmap energy that falls on dark pixels.
    // Prevents H6 firing when saliency hotspot is on a bright window or light source.
    function _analyzeSaliencyDarkRegion(heatmap, grayPixels) {
        if (!heatmap || !grayPixels || heatmap.length !== grayPixels.length) {
            return { darkSaliencyFraction: 0, qualifies: false };
        }
        var sorted = Array.prototype.slice.call(heatmap).sort(function (a, b) { return a - b; });
        var p80 = sorted[Math.floor(sorted.length * 0.80)];
        var darkEnergy = 0; var totalTopEnergy = 0;
        for (var i = 0; i < heatmap.length; i++) {
            if (heatmap[i] >= p80) {
                totalTopEnergy += heatmap[i];
                if (grayPixels[i] < PRIOR.DARK_PIXEL_THRESHOLD) darkEnergy += heatmap[i];
            }
        }
        var frac = totalTopEnergy > 1e-10 ? darkEnergy / totalTopEnergy : 0;
        return { darkSaliencyFraction: frac, qualifies: frac >= PRIOR.MIN_DARK_SALIENCY_FRACTION };
    }

    // Patch-based analysis (H4) lives in _analyzePatchScoresWithCleanup —
    // it is the only variant with failure-path tensor disposal.

    function _analyze(tensor) {
        // Run variance, brightness, and colour saturation in parallel (M5).
        // Frequency must run after to provide _grayData for texture regularity.
        return Promise.all([
            _analyzeVariance(tensor),
            _analyzeBrightness(tensor),
            _analyzeColourSaturation(tensor)
        ]).then(function (results) {
            var variance         = results[0];
            var meanBrightness   = results[1];
            var colourSaturation = results[2];
            return _analyzeFrequency(tensor).then(function (frequency) {
                var combinedFactor = Math.min(variance.factor, frequency.factor);
                // H5: texture regularity — reuses grayscale data from frequency analysis.
                var textureRegularity = _analyzeTextureRegularity(
                    frequency._grayData, frequency._h, frequency._w
                );
                // Strip internal fields from public frequency object.
                var frequencyPublic = {
                    low: frequency.low, mid: frequency.mid, high: frequency.high,
                    total: frequency.total, signature: frequency.signature,
                    factor: frequency.factor, description: frequency.description
                };
                return {
                    variance: variance,
                    frequency: frequencyPublic,
                    combinedFactor: combinedFactor,
                    meanBrightness: meanBrightness,
                    colourSaturation: colourSaturation,
                    textureRegularity: textureRegularity,
                    // Retained for H6 signal 4 (dark-region saliency cross-reference).
                    _grayData: frequency._grayData,
                    _grayH: frequency._h,
                    _grayW: frequency._w
                };
            });
        });
    }

    function _adjustBinaryScore(rawScore, analysis) {
        var adjusted = rawScore * analysis.combinedFactor;
        return {
            raw: rawScore, adjusted: adjusted,
            isMould: adjusted > BINARY_THRESHOLD,
            rawConfidence: rawScore * 100,
            adjustedConfidence: adjusted * 100,
            combinedFactor: analysis.combinedFactor
        };
    }

    function _analyzeSaliency(heatmap) {
        var S = SALIENCY;
        var sum = 0; var peak = 0;
        for (var i = 0; i < heatmap.length; i++) {
            var v = heatmap[i];
            sum += v;
            if (v > peak) peak = v;
        }
        var mean = heatmap.length > 0 ? sum / heatmap.length : 0;
        var peakToMean = mean > 1e-10 ? peak / mean : 1.0;

        var level, boost, description;
        if (peakToMean >= S.HIGH_CONCENTRATION) {
            level = 'HIGH';
            boost = Math.min(S.MAX_BOOST, 1.0 + (peakToMean - S.HIGH_CONCENTRATION) / S.HIGH_CONCENTRATION);
            description = 'Highly localised hotspot — possible small mould colony';
        } else if (peakToMean >= S.MODERATE_CONCENTRATION) {
            level = 'MODERATE';
            boost = 1.0 + 0.5 * (peakToMean - S.MODERATE_CONCENTRATION) / (S.HIGH_CONCENTRATION - S.MODERATE_CONCENTRATION);
            description = 'Moderately localised attention';
        } else {
            level = 'DIFFUSE';
            boost = 1.0;
            description = 'Diffuse attention — no localised signal';
        }
        return { peakToMean: peakToMean, boost: boost, level: level, description: description };
    }

    function _adjustBinaryScoreWithSaliency(rawScore, analysis, saliency, patchResult, heatmap) {
        var S = SALIENCY;
        // DR-011: suppress saliency boost for NEAR_UNIFORM images with elevated
        // high-frequency energy (high > 0.09). Interior rooms score high here
        // (0.093-0.105) due to room detail/noise; mould-on-smooth-wall images
        // have high < 0.05. Prevents GradCAM hotspots on plain walls from
        // lifting a factor-suppressed score back over the 0.50 threshold.
        var smoothIndoor = ((analysis.frequency.signature === 'NEAR_UNIFORM' ||
                             analysis.frequency.signature === 'UNIFORM' ||
                             analysis.frequency.signature === 'FLAT') &&
                            analysis.frequency.high > 0.09);
        var effectiveBoost = smoothIndoor ? 1.0 : saliency.boost;
        var boostedFactor = Math.min(1.0, analysis.combinedFactor * effectiveBoost);
        var adjusted = rawScore * boostedFactor;

        // M6a: Two-tier plausibility gate (DR-002).
        // If factor-adjusted score already indicates mould, use the lower gate (0.15)
        // so small genuine spots can still be rescued by saliency.
        // If factor-adjusted score indicates clean, use the higher gate (0.40) to
        // prevent saliency flipping a confident clean verdict caused by a high raw
        // score that was correctly suppressed by frequency/variance analysis.
        var initialIsMould   = adjusted > BINARY_THRESHOLD;
        var plausibilityGate = initialIsMould ? 0.15 : 0.40;
        // M6b: Brightness guard + smooth-indoor guard (DR-003 + DR-011).
        // Suppress secondary saliency uplift on bright or smooth-indoor images.
        var brightSurface = analysis.meanBrightness > S.BRIGHT_SURFACE_THRESHOLD;
        if (!brightSurface && !smoothIndoor && saliency.level === 'HIGH' && rawScore > plausibilityGate) {
            var saliencyContribution = Math.min(0.75, rawScore * (saliency.peakToMean / S.HIGH_CONCENTRATION) * S.MAX_BOOST);
            adjusted = Math.max(adjusted, saliencyContribution);
        }
        // M6c: H4 patch contribution blend (DR-004).
        // On dark surfaces the global model may underestimate mould that covers
        // the whole frame. Blend in the patch-derived score when patches agree.
        // patchResult comes from _analyzePatchScoresWithCleanup in the pipeline.
        var patchContribution = null;
        var P = PATCH;
        if (patchResult &&
            rawScore > P.PLAUSIBILITY_GATE &&
            analysis.meanBrightness < P.DARK_SURFACE_MAX &&
            patchResult.positiveFraction >= P.MIN_POSITIVE_FRACTION) {
            var patchScore = Math.min(
                P.MAX_CONTRIBUTION,
                patchResult.meanScore * (1 + patchResult.positiveFraction)
            );
            patchContribution = patchScore;
            adjusted = Math.max(adjusted, patchScore);
        }
        // M6d: H6 contextual dark-surface prior (DR-001).
        // Four independent signals must ALL agree before the detection threshold
        // is lowered from 0.50 to 0.30. Prevents false positives on dark paint,
        // dark wallpaper, dark wood panelling, and poorly lit clean rooms.
        var PR = PRIOR;
        var darkMouldConsensus = false;
        var isDarkSurface = analysis.meanBrightness < PR.DARK_SURFACE_THRESHOLD;
        var isIrregular   = analysis.textureRegularity &&
                            analysis.textureRegularity.irregularity >= PR.IRREGULARITY_THRESHOLD;
        var isDesaturated = analysis.colourSaturation && analysis.colourSaturation.isDesaturated;
        var darkSaliency  = (heatmap && analysis._grayData)
            ? _analyzeSaliencyDarkRegion(heatmap, analysis._grayData)
            : { darkSaliencyFraction: 0, qualifies: false };
        if (isDarkSurface && isIrregular && isDesaturated &&
            darkSaliency.qualifies && rawScore > PR.PLAUSIBILITY_GATE) {
            darkMouldConsensus = true;
        }
        return {
            raw: rawScore, adjusted: adjusted,
            isMould: darkMouldConsensus
                ? adjusted > PR.DARK_MOULD_THRESHOLD
                : adjusted > BINARY_THRESHOLD,
            rawConfidence: rawScore * 100,
            adjustedConfidence: adjusted * 100,
            combinedFactor: analysis.combinedFactor,
            boostedFactor: boostedFactor,
            saliencyBoost: saliency.boost,
            saliencyLevel: saliency.level,
            saliencyPeakToMean: saliency.peakToMean,
            patchContribution: patchContribution,
            darkMouldConsensus: darkMouldConsensus,
            priorBoost: 0,
            darkSaliencyFraction: darkSaliency.darkSaliencyFraction,
            textureIrregularity: analysis.textureRegularity ? analysis.textureRegularity.irregularity : null,
            textureIrregularityLevel: analysis.textureRegularity ? analysis.textureRegularity.level : null,
            interChannelDiff: analysis.colourSaturation ? analysis.colourSaturation.interChannelDiff : null
        };
    }

    function _borderAttentionFraction(heatmap, width, height) {
        var bw = Math.round(width * BORDER_RATIO);
        var bh = Math.round(height * BORDER_RATIO);
        var borderSum = 0; var totalSum = 0;
        for (var y = 0; y < height; y++) {
            for (var x = 0; x < width; x++) {
                var val = heatmap[y * width + x];
                totalSum += val;
                if (x < bw || x >= width - bw || y < bh || y >= height - bh) borderSum += val;
            }
        }
        return totalSum < 1e-10 ? 0 : borderSum / totalSum;
    }

    // ── Species ───────────────────────────────────────────────────────────────

    function _entropy(scores) {
        var h = 0;
        for (var i = 0; i < scores.length; i++) {
            var p = scores[i];
            if (p > 1e-10) h -= p * Math.log(p);
        }
        return h;
    }

    function _processSpecies(rawScores, binaryConfidence) {
        // M8: Binary confidence weighting (DR-006).
        // Scale species scores by how confident the binary detector was.
        // Borderline detection (0.5) → weight=0.3; confident (1.0) → weight=1.0.
        // Prevents authoritative species IDs when binary confidence is low.
        var weight = 1.0;
        if (binaryConfidence !== undefined && binaryConfidence !== null) {
            var t = Math.max(0, Math.min(1, (binaryConfidence - 0.5) / 0.5));
            weight = MIN_BINARY_WEIGHT + (1.0 - MIN_BINARY_WEIGHT) * t;
        }

        var e = _entropy(rawScores); // entropy on unweighted scores — reflects true model uncertainty
        var isUncertain = e > UNCERTAINTY_ENTROPY_THRESHOLD || weight < 0.6;

        var ranked = [];
        for (var i = 0; i < rawScores.length; i++) {
            if (i === NO_MOULD_INDEX) continue;
            ranked.push({
                index: i,
                label: SPECIES_LABELS[i],
                color: SPECIES_COLORS[i],
                description: SPECIES_DESCRIPTIONS[i] || '',
                confidence: rawScores[i] * weight * 100,
                raw: rawScores[i]
            });
        }
        ranked.sort(function (a, b) { return b.confidence - a.confidence; });

        var top = ranked[0];
        var runner = ranked.length > 1 ? ranked[1] : null;

        var summaryText;
        if (isUncertain) {
            summaryText = 'The species classification is uncertain — the model cannot confidently distinguish between types. This may indicate mixed mould growth or an unfamiliar species.';
        } else if (top.confidence < SPECIES_MIN_CONFIDENCE) {
            summaryText = 'No individual species reached a confident identification threshold. The mould may be a type not well represented in the training data.';
        } else if (runner && runner.confidence > top.confidence * 0.7) {
            summaryText = 'The image most closely matches ' + top.label + ' (' + top.confidence.toFixed(1) + '%), with ' + runner.label + ' (' + runner.confidence.toFixed(1) + '%) as a close alternative. Multiple species may be present.';
        } else {
            summaryText = 'The image most closely matches ' + top.label + ' at ' + top.confidence.toFixed(1) + '% confidence. ' + top.description;
        }

        var noMouldScore = rawScores[NO_MOULD_INDEX] * weight * 100;
        return { ranked: ranked, top: top, entropy: e, isUncertain: isUncertain, binaryWeight: weight, noMouldScore: noMouldScore, summaryText: summaryText };
    }

    // ── Heatmap canvas → JPEG dataURL ─────────────────────────────────────────

    function _heatmapToDataURL(gcResult, imgElement) {
        try {
            var canvas = document.createElement('canvas');
            canvas.width = gcResult.width;
            canvas.height = gcResult.height;
            var ctx = canvas.getContext('2d');
            ctx.drawImage(imgElement, 0, 0, gcResult.width, gcResult.height);
            var origImageData = ctx.getImageData(0, 0, gcResult.width, gcResult.height);
            var overlayData = GradCAM.overlay(gcResult.heatmap, gcResult.width, gcResult.height, origImageData, 0.55);
            ctx.putImageData(overlayData, 0, 0);
            var result = canvas.toDataURL('image/jpeg', HEATMAP_JPEG_QUALITY);
            // MR-03 FIX: release canvas backing store (~200KB at 224×224)
            canvas.width  = 0;
            canvas.height = 0;
            return result;
        } catch (e) {
            console.warn('LocalCNNService: heatmap canvas failed', e);
            return null;
        }
    }

    // ── Verdict / severity / insight (mirrors MouldDetectService shape) ───────

    function _getVerdict(pct) {
        if (pct >= 80) return 'Very Likely Mould';
        if (pct >= 60) return 'Possible Mould';
        if (pct >= 40) return 'Uncertain';
        return 'Unlikely Mould';
    }

    /**
     * Generate contextual insight text incorporating saliency and shortcut signals.
     * Mirrors the insight logic from reference app.js _drawHeatmap().
     *
     * @param {number}      pct           - final adjusted confidence percentage
     * @param {Object|null} speciesResult - from _processSpecies(), or null
     * @param {string}      saliencyLevel - 'HIGH' | 'MODERATE' | 'DIFFUSE'
     * @param {boolean}     isShortcut    - true if border attention fraction > threshold
     */
    function _getInsight(pct, speciesResult, saliencyLevel, isShortcut) {
        var isMould = pct >= 50;
        var isLocalised = saliencyLevel === 'HIGH' || saliencyLevel === 'MODERATE';

        // Species note — appended when mould detected and species identified
        var speciesNote = '';
        if (speciesResult && speciesResult.top && speciesResult.top.confidence >= SPECIES_MIN_CONFIDENCE) {
            speciesNote = ' The most likely species is ' + speciesResult.top.label + ' (' + speciesResult.top.confidence.toFixed(0) + '% confidence). ' + speciesResult.top.description;
        }

        // Saliency context note
        var saliencyNote = '';
        if (isMould && isLocalised && !isShortcut) {
            saliencyNote = ' A small, localised area of interest was detected — consistent with an early-stage or small mould colony.';
        } else if (isMould && isShortcut) {
            saliencyNote = ' Note: the AI attention was concentrated near the image edges — try cropping the image to focus on the surface area for a more accurate result.';
        } else if (!isMould && isLocalised && !isShortcut) {
            saliencyNote = ' A small area of interest was found but did not reach the detection threshold. If you can see a spot or discolouration, try photographing it more closely.';
        }

        if (pct >= 80) return 'Strong visual indicators of mould growth detected. Immediate professional assessment is recommended.' + speciesNote + saliencyNote;
        if (pct >= 60) return 'Visual patterns consistent with mould growth are present. Consider professional inspection to confirm.' + speciesNote + saliencyNote;
        if (pct >= 40) return 'Some visual indicators detected, but results are inconclusive. Monitor the area and consider a closer photograph.' + speciesNote + saliencyNote;
        return 'No strong mould indicators detected in this image. If you suspect mould, try a closer or better-lit photograph.' + saliencyNote;
    }

    // ── Public API ────────────────────────────────────────────────────────────

    /**
     * load() — Pre-load and warm-up the Local CNN pipeline.
     *
     * Engine context awareness:
     *   - Returns a resolved Promise immediately if AI_ENGINE is not local/hybrid.
     *   - Returns a resolved Promise if already in 'ready' state (idempotent).
     *   - Returns a rejected Promise if already 'loading' (prevents double-load).
     *   - On any failure: calls _resetState() to free GPU memory, sets
     *     _loadState='failed', stores error in _loadError, then rejects.
     *     Callers (App.jsx) catch this and activate the next engine tier.
     *
     * Warm-up: after validation, runs one dummy inference to JIT-compile the
     * TF.js kernel so the first real analyze() call is not penalised.
     *
     * @param {function} [onProgress] - optional stage label callback
     * @returns {Promise<void>}
     */
    function load(onProgress) {
        // Idempotent: already loaded
        if (_loadState === 'ready') {
            console.info('[LocalCNN] Models already loaded and ready.');
            return Promise.resolve();
        }
        // Prevent concurrent loads
        if (_loadState === 'loading') {
            return Promise.reject(new Error('[LocalCNN] Load already in progress.'));
        }
        // Only load when the AI_ENGINE flag selects an on-device engine.
        var aiEngine = FeatureFlags.getValue('AI_ENGINE');
        var localActive = (aiEngine === 'local' || aiEngine === 'hybrid');
        if (!localActive) {
            console.info('[LocalCNN] Not active for current AI_ENGINE value (' + (aiEngine || 'null') + '). Skipping model load.');
            _loadState = 'idle';
            return Promise.resolve();
        }
        // Dependency check: TF.js and MobileNet must be on window
        if (typeof tf === 'undefined') {
            var tfErr = new Error('[LocalCNN] TensorFlow.js (tf) not found. Ensure the TF.js CDN script is loaded before LocalCNNService.');
            console.error(tfErr.message);
            _loadState = 'failed';
            _loadError = tfErr;
            return Promise.reject(tfErr);
        }
        if (typeof window.mobilenet === 'undefined') {
            var mnErr = new Error('[LocalCNN] MobileNet not found. Ensure the @tensorflow-models/mobilenet CDN script is loaded before LocalCNNService.');
            console.error(mnErr.message);
            _loadState = 'failed';
            _loadError = mnErr;
            return Promise.reject(mnErr);
        }

        _loadState = 'loading';
        _loadError  = null;
        var notify = onProgress || function () {};

        console.info('[LocalCNN] Starting model load sequence...');
        notify('Loading AI Vision engine…');

        return window.mobilenet.load({ version: 1, alpha: 1.0 })
            .then(function (mn) {
                _mobilenet = mn;
                console.info('[LocalCNN] MobileNet backbone loaded.');
                notify('Loading classifier heads…');
                var bv = encodeURIComponent(BINARY_MODEL.VERSION);
                var sv = encodeURIComponent(SPECIES_MODEL.VERSION);
                return Promise.all([
                    tf.loadLayersModel(BINARY_MODEL.JSON  + '?v=' + bv),
                    tf.loadLayersModel(SPECIES_MODEL.JSON + '?v=' + sv)
                ]);
            })
            .then(function (heads) {
                _binaryHead  = heads[0];
                _speciesHead = heads[1];
                console.info('[LocalCNN] Binary and species heads loaded.');
                notify('Validating models…');
                return _validate();
            })
            .then(function () {
                // Warm-up: one dummy inference to JIT-compile TF.js kernels.
                // This ensures the first real analyze() call is not penalised
                // by kernel compilation latency (~200-800ms on first run).
                notify('Warming up AI engine…');
                console.info('[LocalCNN] Running warm-up inference...');
                return tf.tidy(function () {
                    var dummy = tf.zeros([1, IMAGE_SIZE, IMAGE_SIZE, 3]);
                    var features = _extractFeatures(dummy);
                    return _binaryHead.predict(features.squeeze().expandDims(0));
                }).data();
            })
            .then(function () {
                _ready     = true;
                _loadState = 'ready';
                _loadError  = null;
                console.info('[LocalCNN] ✅ Models ready. Engine: LOCAL_CNN (on-device, private).');
                notify('Local AI ready');
            })
            ['catch'](function (err) {
                console.error('[LocalCNN] ❌ Model load failed:', err.message);
                console.warn('[LocalCNN] Releasing GPU memory and resetting state for fallback engine selection.');
                _resetState();
                _loadState = 'failed';
                _loadError  = err;
                throw err;
            });
    }

    function isReady() { return _ready; }

    /**
     * getLoadState() — Returns the current load state string.
     * Values: 'idle' | 'loading' | 'ready' | 'failed'
     * Used by App.jsx and AnalysisPage to select the active AI engine.
     */
    function getLoadState() { return _loadState; }

    /**
     * getLoadError() — Returns the Error from the last failed load(), or null.
     * Used by App.jsx to log the reason for fallback engine activation.
     */
    function getLoadError() { return _loadError; }

    /**
     * Full analysis pipeline with proper tensor lifecycle management.
     * PERFORMANCE FIX: Wraps entire analysis in tf.tidy() for automatic disposal
     * and adds explicit disposal guards for all error paths.
     * 
     * @param {HTMLImageElement} imgElement — preview img element (used for heatmap overlay)
     * @param {function} onStage — optional progress callback(stageLabel)
     * @param {File} [file] — optional raw File object. When provided, tensor is built
     *   via FileReader (DR-009) for frequency-consistent pixel values. Falls back
     *   to imgElement when omitted (legacy / no file available).
     * @returns Promise<result>
     */
    function analyze(imgElement, onStage, file) {
        if (!_ready) return Promise.reject(new Error('LocalCNNService not loaded. Call load() first.'));

        var notify = onStage || function () {};
        
        // CRITICAL FIX: Track all tensors for guaranteed cleanup
        var tensorTracker = {
            tensors: [],
            track: function(tensor) {
                if (tensor && typeof tensor.dispose === 'function') {
                    this.tensors.push(tensor);
                }
                return tensor;
            },
            dispose: function() {
                for (var i = 0; i < this.tensors.length; i++) {
                    try {
                        if (this.tensors[i] && !this.tensors[i].isDisposed) {
                            this.tensors[i].dispose();
                        }
                    } catch (e) {
                        console.warn('[LocalCNN] Tensor disposal warning:', e.message);
                    }
                }
                this.tensors = [];
            }
        };

        // M7: Prefer raw-file tensor path when a File object is available (DR-009)
        var tensorPromise = file
            ? _fileToTensor(file)
            : Promise.resolve(_imageToTensor(imgElement));

        notify('Analysing image texture…');
        
        return tensorPromise.then(function (mainTensor) {
            // Track the main tensor for cleanup
            tensorTracker.track(mainTensor);
            
            // Wrap analysis pipeline in proper error handling
            return _analyzeWithMemoryManagement(mainTensor, notify, tensorTracker, imgElement);
        }).catch(function (err) {
            // CRITICAL: Always clean up on any error
            console.error('[LocalCNN] Analysis failed, cleaning up tensors:', err.message);
            tensorTracker.dispose();
            throw err;
        });
    }
    
    /**
     * Internal analysis pipeline with memory management.
     * Separated to allow proper cleanup in outer analyze() function.
     */
    function _analyzeWithMemoryManagement(tensor, notify, tracker, imgElement) {
        var _imageAnalysis, _rawScore, _initialResult, _speciesResult, _gcResult, _saliency, _finalResult, _patchResult, _features;

        return _analyze(tensor).then(function (imageAnalysis) {
            _imageAnalysis = imageAnalysis;
            notify('Running mould detection…');

            // Extracted ONCE and reused for binary, species, and verdict-flip
            // species passes — re-extracting re-ran the entire MobileNet
            // backbone (~100-300ms each on mobile GPU) for an identical result.
            // Owned by the tracker: disposed on success and on any failure.
            _features = tracker.track(tf.tidy(function() { return _extractFeatures(tensor); }));
            return _predictBinary(_features);
        }).then(function (rawScore) {
            _rawScore = rawScore;
            _initialResult = _adjustBinaryScore(rawScore, _imageAnalysis);
            notify(_initialResult.isMould ? 'Mould detected — identifying species…' : 'Checking for species…');

            if (_initialResult.isMould) {
                return _predictSpecies(_features).then(function (speciesScores) {
                    return _processSpecies(speciesScores, _initialResult.adjusted);
                });
            }
            return null;
        }).then(function (speciesResult) {
            _speciesResult = speciesResult;
            notify('Computing attention map…');

            var useSpecies = _initialResult.isMould && speciesResult !== null;
            var classifierModel = useSpecies ? _speciesHead : _binaryHead;
            var classIndex = useSpecies ? speciesResult.top.index : 0;

            // PERFORMANCE FIX: Run GradCAM and patch analysis with proper cleanup
            var gradcamPromise = new Promise(function (resolve, reject) {
                setTimeout(function () {
                    GradCAM.compute(tensor, classifierModel, _mobilenet, classIndex)
                        .then(function (gcResult) { resolve(gcResult); })
                        .catch(function (err) { 
                            console.warn('[LocalCNN] GradCAM failed:', err.message);
                            reject(err); 
                        });
                }, 50);
            });
            
            var patchPromise = (_imageAnalysis.meanBrightness < PATCH.DARK_SURFACE_MAX)
                ? _analyzePatchScoresWithCleanup(tensor, _binaryHead, _mobilenet)
                : Promise.resolve(null);

            return Promise.all([gradcamPromise, patchPromise]);
        }).then(function (results) {
            _gcResult = results[0];
            _patchResult = results[1];
            _saliency = _analyzeSaliency(_gcResult.heatmap);
            _finalResult = _adjustBinaryScoreWithSaliency(_rawScore, _imageAnalysis, _saliency, _patchResult, _gcResult.heatmap);

            // If saliency flipped verdict to mould and species not yet run, run it now
            if (_finalResult.isMould && !_initialResult.isMould && !_speciesResult) {
                return _predictSpecies(_features).then(function (speciesScores) {
                    _speciesResult = _processSpecies(speciesScores, _finalResult.adjusted);
                });
            }
        }).then(function () {
            var pct = Math.round(_finalResult.adjustedConfidence);
            var heatmapDataURL = _heatmapToDataURL(_gcResult, imgElement);
            var borderFraction = _borderAttentionFraction(_gcResult.heatmap, _gcResult.width, _gcResult.height);
            var isShortcut = borderFraction > BORDER_THRESHOLD;
            var sevConfig = SeverityConfig.fromPercentage(pct);
            
            // CRITICAL FIX: Clean up all tracked tensors before returning
            tracker.dispose();

            return {
                // MouldDetectService-compatible fields
                percentage: pct,
                verdict: _getVerdict(pct),
                severity: sevConfig.severity,
                label: _finalResult.isMould ? 'Mould' : 'No Mould',
                insight: _getInsight(pct, _speciesResult, _saliency.level, isShortcut),
                // Local CNN extended fields
                aiEngine: 'local_cnn',
                rawScore: Math.round(_rawScore * 100),
                freqSpectrum: {
                    low: _imageAnalysis.frequency.low,
                    mid: _imageAnalysis.frequency.mid,
                    high: _imageAnalysis.frequency.high,
                    signature: _imageAnalysis.frequency.signature,
                    description: _imageAnalysis.frequency.description
                },
                saliencyLevel: _saliency.level,
                saliencyPeakToMean: _saliency.peakToMean,
                isShortcut: isShortcut,
                heatmapDataURL: heatmapDataURL,
                speciesTop: _speciesResult ? _speciesResult.top : null,
                speciesRanked: _speciesResult ? _speciesResult.ranked : [],
                speciesIsUncertain: _speciesResult ? _speciesResult.isUncertain : false,
                speciesSummary: _speciesResult ? _speciesResult.summaryText : '',
                // M9: New diagnostic fields (H4/H5/H6)
                darkMouldConsensus: _finalResult.darkMouldConsensus,
                patchContribution: _finalResult.patchContribution,
                textureIrregularity: _finalResult.textureIrregularity,
                textureIrregularityLevel: _finalResult.textureIrregularityLevel,
                interChannelDiff: _finalResult.interChannelDiff
            };
        });
    }
    
    /**
     * Patch analysis with proper tensor cleanup.
     * PERFORMANCE FIX: Each patch operation wrapped in tf.tidy()
     */
    function _analyzePatchScoresWithCleanup(tensor, binaryHead, mobilenetModel) {
        var imgSize = IMAGE_SIZE;
        var grid = PATCH.GRID;
        var patchSize = Math.floor(imgSize * PATCH.STRIDE_RATIO * 2);
        var stride = Math.floor(imgSize * PATCH.STRIDE_RATIO);
        var promises = [];
        
        for (var row = 0; row < grid; row++) {
            for (var col = 0; col < grid; col++) {
                (function (r, c) {
                    var y = Math.min(r * stride, imgSize - patchSize);
                    var x = Math.min(c * stride, imgSize - patchSize);
                    
                    // CRITICAL FIX: Wrap each patch operation in tf.tidy()
                    var patchPromise = tf.tidy(function () {
                        var crop = tensor.squeeze().slice([y, x, 0], [patchSize, patchSize, 3]).expandDims(0);
                        var resized = tf.image.resizeBilinear(crop, [imgSize, imgSize]);
                        var preprocessed = tf.sub(tf.mul(resized, 2.0), 1.0);
                        var features = mobilenetModel.infer(preprocessed, true);
                        return binaryHead.predict(features);
                    });
                    
                    promises.push(
                        patchPromise.data().then(function (d) {
                            patchPromise.dispose(); // Clean up prediction tensor
                            return d[0];
                        }).catch(function(err) {
                            // Ensure cleanup on patch failure
                            try { patchPromise.dispose(); } catch(e) {}
                            throw err;
                        })
                    );
                })(row, col);
            }
        }
        
        return Promise.all(promises).then(function (scores) {
            var maxScore = 0; var sum = 0; var positives = 0;
            for (var i = 0; i < scores.length; i++) {
                if (scores[i] > maxScore) maxScore = scores[i];
                sum += scores[i];
                if (scores[i] > PATCH.PER_PATCH_THRESHOLD) positives++;
            }
            var meanScore = sum / scores.length;
            var positiveFraction = positives / scores.length;
            return { maxScore: maxScore, meanScore: meanScore, positiveFraction: positiveFraction, patchScores: scores };
        });
    }

    return { load: load, isReady: isReady, getLoadState: getLoadState, getLoadError: getLoadError, analyze: analyze };
})();

window.LocalCNNService = LocalCNNService;
