Skip to content

Commit

Permalink
Add blob editing tool
Browse files Browse the repository at this point in the history
  • Loading branch information
arjunrajlab committed Jan 29, 2025
1 parent e78ef2f commit 3e0570e
Show file tree
Hide file tree
Showing 3 changed files with 276 additions and 0 deletions.
29 changes: 29 additions & 0 deletions public/config/templates.json
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,35 @@
}
]
},
{
"name": "Annotation Edits",
"type": "edit",
"shortName": "Edit",
"interface": [
{
"name": "Action",
"type": "select",
"id": "action",
"isSubmenu": true,
"meta": {
"items": [
{
"text": "Blob edit",
"value": "blob_edit"
}
]
}
},
{
"name": "Annotations to edit",
"id": "annotation",
"type": "restrictTagsAndLayer",
"meta": {
"inclusiveToggle": true
}
}
]
},
{
"name": "Tagging tools",
"type": "tagging",
Expand Down
245 changes: 245 additions & 0 deletions src/components/AnnotationViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2159,6 +2159,245 @@ export default class AnnotationViewer extends Vue {
);
}
async handleAnnotationEdits(selectAnnotation: IGeoJSAnnotation) {
// Get the selected annotations that intersect with the lasso
const selectedAnnotations =
this.getSelectedAnnotationsFromAnnotation(selectAnnotation);
if (selectedAnnotations.length === 0) {
this.interactionLayer.removeAnnotation(selectAnnotation);
return;
}
// Filter out annotations that are not polygons
const polygonAnnotations = selectedAnnotations.filter(
(annotation) => annotation.shape === AnnotationShape.Polygon,
);
if (polygonAnnotations.length === 0) {
this.interactionLayer.removeAnnotation(selectAnnotation);
return;
}
// Filter out annotations that do not match the tool configuration
const annotationTemplate = this.selectedToolConfiguration?.values
?.annotation as IRestrictTagsAndLayer;
let filteredAnnotations: IAnnotation[] = [];
if (annotationTemplate) {
filteredAnnotations = filterAnnotations(
selectedAnnotations,
annotationTemplate,
);
} else {
filteredAnnotations = polygonAnnotations;
}
if (filteredAnnotations.length === 0) {
this.interactionLayer.removeAnnotation(selectAnnotation);
return;
}
// Edit the first annotation in the list.
// TODO: This logic could be improved by using some more intelligent logic
// to determine which annotation to edit.
const annotationToEdit = filteredAnnotations[0];
await this.annotationStore.updateAnnotationsPerId({
annotationIds: [annotationToEdit.id],
editFunction: (annotation: IAnnotation) => {
const newAnnotation = this.editPolygonAnnotation(
annotation,
selectAnnotation.coordinates(),
);
annotation.coordinates = newAnnotation.coordinates;
},
});
// Remove the temporary selection annotation
this.interactionLayer.removeAnnotation(selectAnnotation);
}
// This function finds the intersection between a line and a line segment.
// It returns the intersection point if it exists, or null if the lines do not intersect.
findIntersection(
lineStart: IGeoJSPosition,
lineEnd: IGeoJSPosition,
segStart: IGeoJSPosition,
segEnd: IGeoJSPosition,
): IGeoJSPosition | null {
const denom =
(segEnd.y - segStart.y) * (lineEnd.x - lineStart.x) -
(segEnd.x - segStart.x) * (lineEnd.y - lineStart.y);
if (Math.abs(denom) < 1e-10) return null; // parallel lines
const ua =
((segEnd.x - segStart.x) * (lineStart.y - segStart.y) -
(segEnd.y - segStart.y) * (lineStart.x - segStart.x)) /
denom;
const ub =
((lineEnd.x - lineStart.x) * (lineStart.y - segStart.y) -
(lineEnd.y - lineStart.y) * (lineStart.x - segStart.x)) /
denom;
if (ua < 0 || ua > 1 || ub < 0 || ub > 1) return null;
return {
x: lineStart.x + ua * (lineEnd.x - lineStart.x),
y: lineStart.y + ua * (lineEnd.y - lineStart.y),
};
}
// This function finds all intersections between a polygon and a line.
// It returns an array of objects, each containing a point, the index of the line segment,
// and the index of the polygon edge.
findAllIntersections(
polygon: IGeoJSPosition[],
newLine: IGeoJSPosition[],
): Array<{ point: IGeoJSPosition; index: number; lineSegmentIndex: number }> {
const intersections: Array<{
point: IGeoJSPosition;
index: number;
lineSegmentIndex: number;
}> = [];
// Check each line segment against each polygon edge
for (let i = 0; i < newLine.length - 1; i++) {
const lineStart = newLine[i];
const lineEnd = newLine[i + 1];
for (let j = 0; j < polygon.length - 1; j++) {
const intersection = this.findIntersection(
lineStart,
lineEnd,
polygon[j],
polygon[j + 1],
);
if (intersection) {
intersections.push({
point: intersection,
index: j + 1,
lineSegmentIndex: i,
});
}
}
}
return intersections;
}
// This function edits the polygon annotation by "carving" with a line.
// It finds the first and last intersections between the polygon and the line,
// and then reverses the line if the first intersection is after the last intersection
// (i.e., the line is in the reverse orientation of the polygon).
// It then splices in the line verticesbetween the first and last intersections.
editPolygonAnnotation(
annotation: IAnnotation,
newLine: IGeoJSPosition[],
): IAnnotation {
if (newLine.length < 2) {
return annotation;
}
const polygon = annotation.coordinates;
// Find all intersections
const intersections = this.findAllIntersections(polygon, newLine);
// Group intersections by line segment
const intersectionsBySegment = intersections.reduce(
(acc, intersection) => {
if (!acc[intersection.lineSegmentIndex]) {
acc[intersection.lineSegmentIndex] = [];
}
acc[intersection.lineSegmentIndex].push(intersection);
return acc;
},
{} as Record<number, typeof intersections>,
);
// Find first and last intersection
let firstIntersection: (typeof intersections)[0] | null = null;
let lastIntersection: (typeof intersections)[0] | null = null;
// Find the first valid intersection
for (let i = 0; i < newLine.length - 1; i++) {
const segmentIntersections = intersectionsBySegment[i];
if (segmentIntersections?.length > 0) {
firstIntersection = segmentIntersections[0];
break;
}
}
// Find the last valid intersection
for (let i = newLine.length - 2; i >= 0; i--) {
const segmentIntersections = intersectionsBySegment[i];
if (segmentIntersections?.length > 0) {
lastIntersection =
segmentIntersections[segmentIntersections.length - 1];
break;
}
}
if (!firstIntersection || !lastIntersection) {
return annotation;
}
// Determine if we need to reverse the line points based on intersection order
const shouldReverseLine = firstIntersection.index > lastIntersection.index;
// Get the relevant line points
let linePoints: IGeoJSPosition[];
if (shouldReverseLine) {
linePoints = newLine
.slice(
firstIntersection.lineSegmentIndex + 1,
lastIntersection.lineSegmentIndex + 1,
)
.reverse();
} else {
linePoints = newLine.slice(
firstIntersection.lineSegmentIndex + 1,
lastIntersection.lineSegmentIndex + 1,
);
}
// Create new coordinates array
const newCoordinates: IGeoJSPosition[] = [
...polygon.slice(
0,
Math.min(firstIntersection.index, lastIntersection.index),
),
shouldReverseLine ? lastIntersection.point : firstIntersection.point,
...linePoints,
shouldReverseLine ? firstIntersection.point : lastIntersection.point,
...polygon.slice(
Math.max(firstIntersection.index, lastIntersection.index),
),
];
// Ensure the polygon is closed
if (
!this.pointsEqual(
newCoordinates[0],
newCoordinates[newCoordinates.length - 1],
)
) {
newCoordinates.push({ ...newCoordinates[0] });
}
return {
...annotation,
coordinates: newCoordinates,
};
}
// Helper function to check if two points are equal
pointsEqual(a: IGeoJSPosition, b: IGeoJSPosition): boolean {
return Math.abs(a.x - b.x) < 1e-10 && Math.abs(a.y - b.y) < 1e-10;
}
handleNewROIFilter(geojsAnnotation: IGeoJSAnnotation) {
if (!this.roiFilter) {
return;
Expand Down Expand Up @@ -2320,6 +2559,9 @@ export default class AnnotationViewer extends Vue {
: "polygon";
this.interactionLayer.mode(selectionType);
break;
case "edit":
this.interactionLayer.mode("line");
break;
case "samAnnotation":
case null:
case undefined:
Expand Down Expand Up @@ -2414,6 +2656,9 @@ export default class AnnotationViewer extends Vue {
case "connection":
this.handleAnnotationConnections(evt.annotation);
break;
case "edit":
this.handleAnnotationEdits(evt.annotation);
break;
}
} else {
this.handleNewROIFilter(evt.annotation);
Expand Down
2 changes: 2 additions & 0 deletions src/tools/creation/ToolTypeSelection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface TReturnType {
selectedItem: AugmentedItem | null;
}
// This functionality is here to keep some tool types hidden from the user,
// but available for later implementation.
const hiddenToolTexts = new Set<string>([
'"Snap to" manual annotation tools',
"Annotation edit tools",
Expand Down

0 comments on commit 3e0570e

Please sign in to comment.