Files
App/www/map-occupancy-canvas.js
HiepLM 90e8e9d252
Some checks failed
Test / test (push) Has been cancelled
Xong phần map viewer
2026-06-20 09:18:19 +02:00

179 lines
5.3 KiB
JavaScript

(() => {
/**
* RViz-style occupancy grid renderer for map view (Canvas 2D).
*
* ROS map_server / nav_msgs/OccupancyGrid:
* 0 = free
* 100 = occupied
* -1 = unknown
*
* Grid data is row-major with index 0 at the bottom-left cell (world +Y up).
* Canvas pixels use top-left origin — Y is flipped when painting.
*/
const DEFAULT_PALETTE = {
free: [254, 254, 254, 255],
occupied: [0, 0, 0, 255],
unknown: [180, 180, 180, 255],
};
const DEFAULT_THRESHOLDS = {
occupied_thresh: 0.65,
free_thresh: 0.196,
negate: 0,
};
function metaThresholds(meta) {
return {
occupied_thresh: Number(meta?.occupied_thresh ?? DEFAULT_THRESHOLDS.occupied_thresh),
free_thresh: Number(meta?.free_thresh ?? DEFAULT_THRESHOLDS.free_thresh),
negate: Number(meta?.negate ?? DEFAULT_THRESHOLDS.negate),
};
}
/** Trinary mode (Nav2/map_server): darkness or lightness vs thresholds in [0, 1]. */
function grayToOccValue(gray, meta) {
const { occupied_thresh, free_thresh, negate } = metaThresholds(meta);
const lightness = gray / 255;
const probability = negate ? lightness : 1 - lightness;
if (probability > occupied_thresh) return 100;
if (probability < free_thresh) return 0;
return -1;
}
function occToRgba(value, palette) {
if (value < 0) return palette.unknown;
if (value >= 100) return palette.occupied;
if (value === 0) return palette.free;
return value > 50 ? palette.occupied : palette.free;
}
function paletteFrom(opts) {
const p = opts?.palette || {};
return {
free: p.free || DEFAULT_PALETTE.free,
occupied: p.occupied || DEFAULT_PALETTE.occupied,
unknown: p.unknown || DEFAULT_PALETTE.unknown,
};
}
function ensureCanvasSize(canvas, width, height) {
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function decodeGridData(data) {
if (data instanceof Int8Array || data instanceof Uint8Array) return data;
if (Array.isArray(data)) return data;
if (typeof data === "string" && data.length) {
try {
const binary = atob(data);
const out = new Int8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
} catch {
return [];
}
}
return [];
}
/**
* Paint ROS occupancy grid onto canvas.
* @param {HTMLCanvasElement} canvas
* @param {{ width: number, height: number, data: number[]|Int8Array|string }} grid
* @param {{ palette?: object }} [opts]
*/
function renderGrid(canvas, grid, opts = {}) {
if (!canvas || !grid) return false;
const width = Number(grid.width) | 0;
const height = Number(grid.height) | 0;
if (!width || !height) return false;
const data = decodeGridData(grid.data);
if (!data.length) return false;
ensureCanvasSize(canvas, width, height);
const ctx = canvas.getContext("2d");
if (!ctx) return false;
const palette = paletteFrom(opts);
const imageData = ctx.createImageData(width, height);
const px = imageData.data;
for (let row = 0; row < height; row++) {
const srcRow = height - 1 - row;
for (let col = 0; col < width; col++) {
const idx = srcRow * width + col;
const value = idx < data.length ? data[idx] : -1;
const rgba = occToRgba(value, palette);
const dst = (row * width + col) * 4;
px[dst] = rgba[0];
px[dst + 1] = rgba[1];
px[dst + 2] = rgba[2];
px[dst + 3] = rgba[3];
}
}
ctx.putImageData(imageData, 0, 0);
return true;
}
/**
* Convert map_server grayscale image to RViz-style occupancy colors.
* @param {HTMLCanvasElement} canvas
* @param {CanvasImageSource} source
* @param {object} [meta] — negate, occupied_thresh, free_thresh
* @param {{ palette?: object }} [opts]
*/
function renderFromImage(canvas, source, meta = {}, opts = {}) {
if (!canvas || !source) return false;
const width = source.naturalWidth || source.videoWidth || source.width;
const height = source.naturalHeight || source.videoHeight || source.height;
if (!width || !height) return false;
ensureCanvasSize(canvas, width, height);
const ctx = canvas.getContext("2d");
if (!ctx) return false;
const scratch = document.createElement("canvas");
scratch.width = width;
scratch.height = height;
const sctx = scratch.getContext("2d");
if (!sctx) return false;
sctx.drawImage(source, 0, 0, width, height);
const sampled = sctx.getImageData(0, 0, width, height);
const src = sampled.data;
const palette = paletteFrom(opts);
const imageData = ctx.createImageData(width, height);
const dst = imageData.data;
const thresholds = metaThresholds(meta);
for (let i = 0, p = 0; p < width * height; p++, i += 4) {
const gray = src[i];
const occ = grayToOccValue(gray, thresholds);
const rgba = occToRgba(occ, palette);
dst[i] = rgba[0];
dst[i + 1] = rgba[1];
dst[i + 2] = rgba[2];
dst[i + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
return true;
}
window.MapOccupancyCanvas = {
DEFAULT_PALETTE,
DEFAULT_THRESHOLDS,
grayToOccValue,
occToRgba,
decodeGridData,
renderGrid,
renderFromImage,
};
})();