Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
944 changes: 944 additions & 0 deletions frontend/src/axon/OverviewRegistrationWizard.js

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions frontend/src/axon/WellSelectorCanvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as experimentSlice from "../state/slices/ExperimentSlice.js";
import * as positionSlice from "../state/slices/PositionSlice.js";
import * as objectiveSlice from "../state/slices/ObjectiveSlice.js";
import * as focusMapSlice from "../state/slices/FocusMapSlice.js";
import * as overviewRegSlice from "../state/slices/OverviewRegistrationSlice.js";

import * as wsUtils from "./WellSelectorUtils.js";
import apiPositionerControllerMovePositioner from "../backendapi/apiPositionerControllerMovePositioner.js";
Expand Down Expand Up @@ -72,6 +73,10 @@ const WellSelectorCanvas = forwardRef((props, ref) => {
const objectiveState = useSelector(objectiveSlice.getObjectiveState);
const focusMapState = useSelector(focusMapSlice.getFocusMapState);
const focusMapManualPoints = useSelector(focusMapSlice.getManualPoints);
const overviewRegState = useSelector(overviewRegSlice.getOverviewRegistrationState);

// Preloaded overlay images cache (HTML Image objects)
const overlayImagesRef = useRef({});

//##################################################################################
useImperativeHandle(ref, () => ({
Expand Down Expand Up @@ -146,6 +151,9 @@ const WellSelectorCanvas = forwardRef((props, ref) => {
positionState,
objectiveState,
positionHistory,
overviewRegState.overlayEnabled,
overviewRegState.overlayOpacity,
overviewRegState.overlayData,
]);

//##################################################################################
Expand Down Expand Up @@ -812,12 +820,62 @@ const WellSelectorCanvas = forwardRef((props, ref) => {
//------------ draw position trace
drawPositionTrace(ctx);

//------------ draw overview camera overlay images
drawOverviewOverlay(ctx);

//------------ draw focus map points overlay
drawFocusMapOverlay(ctx);

//ctx.restore();
};

//##################################################################################
// Draw overview camera overlay images on the well selector canvas
const drawOverviewOverlay = (ctx) => {
if (!overviewRegState.overlayEnabled) return;
const overlayData = overviewRegState.overlayData;
if (!overlayData || !overlayData.slides) return;

const alpha = overviewRegState.overlayOpacity;
ctx.save();
ctx.globalAlpha = alpha;

Object.values(overlayData.slides).forEach((slideData) => {
if (!slideData.imageBase64 || !slideData.stageBounds) return;

const bounds = slideData.stageBounds;
const imgKey = slideData.slotId + "_" + (slideData.updatedAt || "");

// Check if image is already cached
let cachedImg = overlayImagesRef.current[imgKey];
if (!cachedImg) {
// Load image and cache it
const img = new Image();
img.src = `data:${slideData.imageMimeType || "image/png"};base64,${slideData.imageBase64}`;
img.onload = () => {
overlayImagesRef.current[imgKey] = img;
// Trigger re-render by requesting animation frame
requestAnimationFrame(() => renderCanvas());
Comment on lines +846 to +858
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overlay image cache key includes updatedAt (slotId + "_" + updatedAt), but old cache entries are never removed. Over time (especially with repeated refreshes) this will grow overlayImagesRef.current unbounded. Consider caching per slotId and replacing the entry when updatedAt changes, or pruning keys that are no longer present in overlayData.slides.

Copilot uses AI. Check for mistakes.
};
return; // Skip this slide until image is loaded
Comment on lines +847 to +860
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drawOverviewOverlay() creates a new Image() on every render until onload fires because the image isn’t placed into the cache until after load. If renderCanvas() is triggered frequently, this can start multiple duplicate loads for the same slide. Cache the Image object immediately (or track a "loading" sentinel) so subsequent renders reuse the in-flight request.

Copilot uses AI. Check for mistakes.
}

// Convert stage bounds to canvas pixel coordinates
const px = calcPhy2Px(bounds.minX);
const py = calcPhy2Px(bounds.minY);
const pw = calcPhy2Px(bounds.width);
const ph = calcPhy2Px(bounds.height);

try {
ctx.drawImage(cachedImg, px, py, pw, ph);
} catch (e) {
// Image may not be loaded yet
}
});

ctx.restore();
};

//##################################################################################
// Draw focus map grid points as black crosses and manual points as blue circles
const drawFocusMapOverlay = (ctx) => {
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/axon/WellSelectorComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import InfoPopup from "./InfoPopup.js";
import * as wellSelectorSlice from "../state/slices/WellSelectorSlice.js";
import * as experimentSlice from "../state/slices/ExperimentSlice.js";
import * as positionSlice from "../state/slices/PositionSlice.js";
import * as overviewRegSlice from '../state/slices/OverviewRegistrationSlice.js';

import apiDownloadJson from "../backendapi/apiDownloadJson.js";
import apiGetOverviewOverlayData from "../backendapi/apiGetOverviewOverlayData.js";
import OverviewRegistrationWizard from "./OverviewRegistrationWizard.js";

import {
Button,
Expand Down Expand Up @@ -53,6 +56,37 @@ const WellSelectorComponent = () => {
const wellSelectorState = useSelector(wellSelectorSlice.getWellSelectorState);
const experimentState = useSelector(experimentSlice.getExperimentState);
const positionState = useSelector(positionSlice.getPositionState);
const overviewRegState = useSelector(overviewRegSlice.getOverviewRegistrationState);


//##################################################################################
const handleOpenOverviewWizard = () => {
dispatch(overviewRegSlice.setWizardOpen(true));
};

const handleOverlayToggle = (event) => {
dispatch(overviewRegSlice.setOverlayEnabled(event.target.checked));
// Load overlay data if enabling and not yet loaded
if (event.target.checked && (!overviewRegState.overlayData || !overviewRegState.overlayData.slides || Object.keys(overviewRegState.overlayData.slides || {}).length === 0)) {
loadOverlayData();
}
};

const handleOverlayOpacityChange = (event, newValue) => {
dispatch(overviewRegSlice.setOverlayOpacity(newValue));
};

const loadOverlayData = async () => {
try {
const data = await apiGetOverviewOverlayData(
overviewRegState.cameraName,
overviewRegState.layoutName || experimentState.wellLayout.name
);
dispatch(overviewRegSlice.setOverlayData(data));
} catch (e) {
console.warn("Failed to load overlay data:", e);
}
};


//##################################################################################
Expand Down Expand Up @@ -359,6 +393,50 @@ const WellSelectorComponent = () => {
</div>

<InfoPopup ref={infoPopupRef}/>

{/* Overview Camera Overlay Controls */}
<Accordion sx={{ mt: 1 }} defaultExpanded={false}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="body2">Overview Camera Overlay</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap", mb: 1 }}>
<Button
variant="contained"
size="small"
onClick={handleOpenOverviewWizard}
>
Overview Overlay Wizard
</Button>
<label style={{ fontSize: "14px", display: "flex", alignItems: "center", gap: "4px" }}>
<input
type="checkbox"
checked={overviewRegState.overlayEnabled}
onChange={handleOverlayToggle}
/>
Show Overlay
</label>
</Box>
{overviewRegState.overlayEnabled && (
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mt: 1 }}>
<Typography variant="caption" sx={{ minWidth: 60 }}>Opacity:</Typography>
<Slider
value={overviewRegState.overlayOpacity}
onChange={handleOverlayOpacityChange}
min={0}
max={1}
step={0.05}
size="small"
sx={{ maxWidth: 200 }}
/>
<Typography variant="caption">{Math.round(overviewRegState.overlayOpacity * 100)}%</Typography>
</Box>
)}
</AccordionDetails>
</Accordion>

{/* Overview Registration Wizard Dialog */}
<OverviewRegistrationWizard />
</div>
);
};
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/backendapi/apiGetOverviewOverlayData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import createAxiosInstance from "./createAxiosInstance";

/**
* Get all overlay data for WellSelector canvas rendering.
* GET /ExperimentController/getOverviewOverlayData
*/
const apiGetOverviewOverlayData = async (
cameraName = "",
layoutName = "Heidstar 4x Histosample"
) => {
const axiosInstance = createAxiosInstance();
const response = await axiosInstance.get(
"/ExperimentController/getOverviewOverlayData",
{
params: { camera_name: cameraName, layout_name: layoutName },
}
);
return response.data;
};

export default apiGetOverviewOverlayData;
28 changes: 28 additions & 0 deletions frontend/src/backendapi/apiGetOverviewRegistrationConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import createAxiosInstance from "./createAxiosInstance";

/**
* Get overview registration wizard config with slot definitions.
* POST /ExperimentController/getOverviewRegistrationConfig
*
* Sends the frontend's current wellLayout (with offsets applied) so
* that slot corners returned by the backend match the canvas exactly.
*
* @param {object|null} layoutData - Full wellLayout object from Redux (preferred)
* @param {string} layoutName - Fallback layout name if layoutData is null
*/
const apiGetOverviewRegistrationConfig = async (
layoutData = null,
layoutName = "Heidstar 4x Histosample"
) => {
const axiosInstance = createAxiosInstance();
const response = await axiosInstance.post(
"/ExperimentController/getOverviewRegistrationConfig",
{
layout_data: layoutData,
layout_name: layoutName,
}
);
return response.data;
};

export default apiGetOverviewRegistrationConfig;
21 changes: 21 additions & 0 deletions frontend/src/backendapi/apiGetOverviewRegistrationStatus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import createAxiosInstance from "./createAxiosInstance";

/**
* Get registration status for all slides.
* GET /ExperimentController/getOverviewRegistrationStatus
*/
const apiGetOverviewRegistrationStatus = async (
cameraName = "",
layoutName = "Heidstar 4x Histosample"
) => {
const axiosInstance = createAxiosInstance();
const response = await axiosInstance.get(
"/ExperimentController/getOverviewRegistrationStatus",
{
params: { camera_name: cameraName, layout_name: layoutName },
}
);
return response.data;
};

export default apiGetOverviewRegistrationStatus;
23 changes: 23 additions & 0 deletions frontend/src/backendapi/apiRefreshOverviewSlideImage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import createAxiosInstance from "./createAxiosInstance";

/**
* Refresh overlay image for a slide using existing registration.
* POST /ExperimentController/refreshOverviewSlideImage
*/
const apiRefreshOverviewSlideImage = async (
slotId = "1",
cameraName = "",
layoutName = "Heidstar 4x Histosample"
) => {
const axiosInstance = createAxiosInstance();
let url = "/ExperimentController/refreshOverviewSlideImage?";
const params = [];
params.push(`slot_id=${encodeURIComponent(slotId)}`);
if (cameraName) params.push(`camera_name=${encodeURIComponent(cameraName)}`);
if (layoutName) params.push(`layout_name=${encodeURIComponent(layoutName)}`);
url += params.join("&");
const response = await axiosInstance.post(url);
return response.data;
};

export default apiRefreshOverviewSlideImage;
16 changes: 16 additions & 0 deletions frontend/src/backendapi/apiRegisterOverviewSlide.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import createAxiosInstance from "./createAxiosInstance";

/**
* Register a slide with corner picks – compute homography.
* POST /ExperimentController/registerOverviewSlide
*/
const apiRegisterOverviewSlide = async (registrationData) => {
const axiosInstance = createAxiosInstance();
const response = await axiosInstance.post(
"/ExperimentController/registerOverviewSlide",
registrationData
);
return response.data;
};

export default apiRegisterOverviewSlide;
18 changes: 18 additions & 0 deletions frontend/src/backendapi/apiSnapOverviewImage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import createAxiosInstance from "./createAxiosInstance";

/**
* Snap overview image for a given slot.
* POST /ExperimentController/snapOverviewImage
*/
const apiSnapOverviewImage = async (slotId = "1", cameraName = "") => {
const axiosInstance = createAxiosInstance();
let url = "/ExperimentController/snapOverviewImage?";
const params = [];
params.push(`slot_id=${encodeURIComponent(slotId)}`);
if (cameraName) params.push(`camera_name=${encodeURIComponent(cameraName)}`);
url += params.join("&");
const response = await axiosInstance.post(url);
return response.data;
};

export default apiSnapOverviewImage;
Loading
Loading