Cloudless Mosaic, PlanetScope

//VERSION=3
//Cloudless Mosaic with PlanetScope
//adapted by AndrĂ¡s Zlinszky and Google Gemini to handle first quartile or median mosaicking

function setup() {
    return {
        input: ["red", "green", "blue", "dataMask", "clear"],
        output: { bands: 4 },
        mosaicking: "ORBIT", // 'ORBIT' or 'TILE' or 'NONE' - determines initial mosaicking behavior
        // Define a custom parameter for the mosaicking method
        processing: {
            // 'mosaickingMethod' is the name of your custom parameter.
            // Users can set this to 'median' or 'q1'.
            mosaickingMethod: {
                defaultValue: "q1", // Default to first quartile
                validValues: ["median", "q1"],
            },
        },
    };
}

function preProcessScenes(collections) {
    collections.scenes.orbits = collections.scenes.orbits.filter(function (
        orbit
    ) {
        var orbitDateFrom = new Date(orbit.dateFrom);
        const nAggregationDays = 7;
        return (
            orbitDateFrom.getTime() >=
            collections.to.getTime() - nAggregationDays * 24 * 3600 * 1000
        );
    });
    return collections;
}

function getLastObservation(arr) {
    for (let i = arr.length - 1; i >= 0; i--) {
        if (arr[i] !== 0) {
            return arr[i];
        }
    }
    return 0;
}

function getMedian(sortedValues) {
    const n = sortedValues.length;
    if (n === 0) {
        return undefined;
    }

    const mid = Math.floor(n / 2);

    if (n % 2 === 1) {
        return sortedValues[mid];
    } else {
        return (sortedValues[mid - 1] + sortedValues[mid]) / 2;
    }
}

/**
 * Calculates the first quartile (Q1) of a sorted array of numbers.
 *
 * @param {number[]} sortedValues An array of numbers sorted in ascending order.
 * @returns {number} The first quartile (Q1) of the distribution, or undefined if array is empty.
 */
function getFirstQuartile(sortedValues) {
    const n = sortedValues.length;
    if (n === 0) {
        return undefined;
    }

    const lowerHalfEndIndex = Math.floor(n / 2);
    const lowerHalf = sortedValues.slice(0, lowerHalfEndIndex);

    return getMedian(lowerHalf);
}

// *** CRITICAL CHANGE HERE: ADD 'properties' AS THE THIRD ARGUMENT ***
function evaluatePixel(samples, scenes, properties) {
    var reds = [];
    var greens = [];
    var blues = [];

    // Collect clear samples
    for (var i = 0; i < samples.length; i++) {
        var sample = samples[i];
        var clear = sample.dataMask && sample.clear;

        if (clear === 1) {
            reds.push(sample.red);
            blues.push(sample.blue);
            greens.push(sample.green);
        }
    }

    var rValue;
    var gValue;
    var bValue;
    var transparency;

    if (reds.length > 0) {
        // IMPORTANT: Sort the arrays by value before calculating statistics.
        reds.sort((a, b) => a - b);
        greens.sort((a, b) => a - b);
        blues.sort((a, b) => a - b);

        // *** Access the method from the 'properties' object ***
        const method = properties.mosaickingMethod; // No 'processing' here, directly under properties

        if (method === "median") {
            rValue = getMedian(reds);
            gValue = getMedian(greens);
            bValue = getMedian(blues);
        } else if (method === "q1") {
            rValue = getFirstQuartile(reds);
            gValue = getFirstQuartile(greens);
            bValue = getFirstQuartile(blues);
        } else {
            // Fallback in case of an unexpected method value (shouldn't happen with validValues)
            rValue = reds[0];
            gValue = greens[0];
            bValue = blues[0];
        }

        transparency = 1;
    } else {
        // If no clear samples, default to black and fully transparent.
        rValue = 0;
        gValue = 0;
        bValue = 0;
        transparency = 0;
    }

    // Scale values for display (e.g., to 0-1 range).
    return [
        Math.min(1, Math.max(0, rValue / 3000)),
        Math.min(1, Math.max(0, gValue / 3000)),
        Math.min(1, Math.max(0, bValue / 3000)),
        transparency
    ];
}

General description

The cloudless mosaic script composites together observations across a time range, which is set as 7 days in this script. Pixels which are not clear in the Useable Data Mask (UDM) are removed. There are two options for functions to select which scene to get the pixel from, getLastObservation and getMedian. getLastObservation will put the most recent non cloudy pixel on top. getMedian will remove outliers due to shadows or cloud haze that may have been missed by the UDM algorithm.

Below is an example of several PlanetScope scenes being composited across an area that spans several strips and contains gaps when viewed per day. The script returns a continuous image without gaps.

Cloudless mosaic