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
2 changes: 2 additions & 0 deletions modules/annotations/annotations.js
Original file line number Diff line number Diff line change
Expand Up @@ -2200,6 +2200,8 @@ in order to work. Did you maybe named the ${type} factory implementation differe
}
self.checkLayer(obj);
self.checkAnnotation(obj);
const factory = self.getAnnotationObjectFactory(obj.factoryID);
factory?.configure?.(obj, obj);
Comment on lines +2203 to +2204
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Passing the entire fabric object obj as the options parameter to factory.configure is highly inefficient and dangerous. In Ruler.configure, $.extend({}, options, ...) and Object.assign({}, options) are called, which will attempt to clone/copy all properties of the fabric instance (including circular references, canvas references, etc.). This can lead to severe performance degradation, memory bloat, or runtime errors.

Instead, extract and pass only the necessary properties as a plain object.

const factory = self.getAnnotationObjectFactory(obj.factoryID);
factory?.configure?.(obj, {
	color: obj.color,
	presetID: obj.presetID,
	zoomAtCreation: obj.zoomAtCreation,
	layerID: obj.layerID
});

obj.on('selected', self._objectClicked.bind(self));
obj.on('deselected', self._objectDeselected.bind(self));
_this.insertAt(obj, insertion++);
Expand Down
101 changes: 84 additions & 17 deletions modules/annotations/convert/asapXml.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
//ASAP XML not yet fully tested, does not render well all objects, problem with hierarchies
const ASAP_TYPE_BY_FACTORY = {
rect: "Rectangle",
polygon: "Polygon",
multipolygon: "Polygon",
ellipse: "Polygon",
polyline: "Spline",
line: "Spline",
ruler: "Spline",
point: "Dot",
text: "Dot",
};
const ASAP_LOSSY_FACTORIES = new Set([
"multipolygon", "ellipse", "polyline", "line", "ruler", "text"
]);

OSDAnnotations.Convertor.register("asap-xml", class extends OSDAnnotations.Convertor.IConvertor {
static title = 'ASAP-XML Annotations';
static description = 'ASAP-compatible XML Annotations Format';
Expand Down Expand Up @@ -52,6 +67,7 @@ OSDAnnotations.Convertor.register("asap-xml", class extends OSDAnnotations.Conve
const result = {};

let wasError = '';
const lossyTypes = new Set();

let doc = document.implementation.createDocument("", "", null);
const presetsIdSet = new Set();
Expand All @@ -70,9 +86,7 @@ OSDAnnotations.Convertor.register("asap-xml", class extends OSDAnnotations.Conve

let factory = this.context.getAnnotationObjectFactory(obj.factoryID);
if (factory) {
// Todo some better reporting mechanism
if (factory.factoryID === "multipolygon") {
wasError = 'ASAP XML Does not support multipolygons - saved as polygon.';
coordinates = this.context.polygonFactory.toPointArray({points: obj.points[0]},
OSDAnnotations.AnnotationObjectFactory.withArrayPoint);
} else {
Expand All @@ -84,7 +98,21 @@ OSDAnnotations.Convertor.register("asap-xml", class extends OSDAnnotations.Conve
continue;
}
}
xml_annotation.setAttribute("Type", "Polygon");
const asapType = ASAP_TYPE_BY_FACTORY[factory?.factoryID] || "Polygon";
xml_annotation.setAttribute("Type", asapType);
// Per-annotation factoryID (custom attribute, ASAP ignores unknown attrs).
// Required because a preset's FactoryID is a default, but its annotations may differ.
if (factory?.factoryID) {
xml_annotation.setAttribute("xopatFactoryID", factory.factoryID);
}
if (factory && ASAP_LOSSY_FACTORIES.has(factory.factoryID)) {
lossyTypes.add(factory.factoryID);
}
if (factory?.factoryID === "ruler") {
const inner = obj._objects || obj.objects;
const txt = inner?.[1]?.text;
if (txt) xml_annotation.setAttribute("Description", txt);
}

//todo attr name could be set from preset
xml_annotation.setAttribute("Name", "Annotation " + i);
Expand Down Expand Up @@ -150,9 +178,14 @@ OSDAnnotations.Convertor.register("asap-xml", class extends OSDAnnotations.Conve
//todo check for consitency presetsIdSet?
}

if (lossyTypes.size) {
const lossyMsg = `ASAP-XML is lossy for: ${[...lossyTypes].join(", ")}. `
+ `Re-import in xopat preserves class via preset FactoryID; other tools see only the geometry.`;
wasError = wasError ? wasError + "\n" + lossyMsg : lossyMsg;
}
// Todo - create some unified checking mechanism that reports on export issues
if (wasError) {
Dialogs.show(wasError, 15000, Dialogs.MSG_ERR);
Dialogs.show(wasError, 15000, Dialogs.MSG_WARN);
}

return result;
Expand Down Expand Up @@ -210,22 +243,56 @@ OSDAnnotations.Convertor.register("asap-xml", class extends OSDAnnotations.Conve
for (const coordElem of coords.getElementsByTagName("Coordinate")) {
const index = Number.parseInt(coordElem.getAttribute("Order"));
pointArray[index] = {
x: Number.parseInt(coordElem.getAttribute("X")),
y: Number.parseInt(coordElem.getAttribute("Y"))
x: Number.parseFloat(coordElem.getAttribute("X")),
y: Number.parseFloat(coordElem.getAttribute("Y"))
}
}

const presetID = elem.getAttribute("PartOfGroup");

//todo support: Dot, Rectangle, Polygon, Spline, and PointSet by implementation of general annotation structure
//todo attr name could be set as category custom meta
annotations.push({
type: "polygon",
points: pointArray,
presetID: presetID,
factoryID: "polygon",
color: elem.getAttribute("Color") || undefined
});
const rawPresetID = elem.getAttribute("PartOfGroup");
const presetID = Number.parseInt(rawPresetID) || rawPresetID;
const preset = presets[presetID];
// Prefer per-annotation factoryID (custom attribute) over the preset's default factoryID:
// a preset's FactoryID is just the default; its annotations may be of other types.
const xopatFacID = elem.getAttribute("xopatFactoryID");
let facID = xopatFacID || preset?.factoryID || "polygon";
// ASAP-XML drops multipolygon rings on export (only outer ring of first polygon is kept);
// decode that single ring as a polygon to avoid Multipolygon.fromPointArray misinterpreting flat points.
if (facID === "multipolygon") facID = "polygon";
const factory = this.context.getAnnotationObjectFactory(facID);
const color = elem.getAttribute("Color") || preset?.color || undefined;

let pushed = false;
if (factory && typeof factory.fromPointArray === "function" &&
typeof factory.create === "function") {
try {
const arrPts = pointArray.map(p => [p.x, p.y]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If pointArray is sparse (e.g., due to missing or non-sequential Order indices in the XML), pointArray.map will preserve the empty slots as undefined. When passed to fromPointArray, destructuring undefined will throw a TypeError and crash the import process.

Filter out empty/undefined slots to ensure a dense array of coordinates is passed to the factory.

Suggested change
const arrPts = pointArray.map(p => [p.x, p.y]);
const arrPts = pointArray.filter(p => p).map(p => [p.x, p.y]);

const params = factory.fromPointArray(arrPts, ([x, y]) => ({ x, y }));
const opts = {
color,
presetID,
factoryID: facID,
...(this.context.presets.getCommonProperties?.() || {}),
};
const obj = factory.create(params, opts);
if (facID === "ruler") {
const desc = elem.getAttribute("Description");
if (desc && obj?._objects?.[1]) obj._objects[1].set({ text: desc });
}
annotations.push(obj);
pushed = true;
} catch (e) {
console.warn("ASAP-XML decode: factory reconstruction failed, falling back to polygon", facID, e);
}
}
if (!pushed) {
annotations.push({
type: "polygon",
points: pointArray,
presetID,
factoryID: "polygon",
color
});
}
}

return {
Expand Down
23 changes: 21 additions & 2 deletions modules/annotations/convert/geoJSON.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ OSDAnnotations.Convertor.register("geo-json", class extends OSDAnnotations.Conve
return res;
},
"polyline": (object) => this._asGEOJsonFeature(object, "LineString", ["points"], false),
"line": (object) => this._asGEOJsonFeature(object, "LineString", [], false),
"point": (object) => {
object = this._asGEOJsonFeature(object, "Point");
object.geometry.coordinates = object.geometry.coordinates[0] || [];
Expand All @@ -89,12 +90,15 @@ OSDAnnotations.Convertor.register("geo-json", class extends OSDAnnotations.Conve
"ruler": (object) => {
const factory = this.context.getAnnotationObjectFactory(object.factoryID);
const converter = OSDAnnotations.AnnotationObjectFactory.withArrayPoint;
object._objects = object.objects; // todo ugly, factory works with live objects
const props = factory.copyNecessaryProperties(object, [], true);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If factory is undefined, calling factory.copyNecessaryProperties will throw a TypeError. Add a null check to prevent potential crashes if the ruler factory is not registered.

Suggested change
const props = factory.copyNecessaryProperties(object, [], true);
const props = factory ? factory.copyNecessaryProperties(object, [], true) : {};

props.text = object._objects?.[1]?.text;
return {
geometry: {
type: "LineString",
coordinates: factory?.toPointArray(object, converter, fabric.Object.NUM_FRACTION_DIGITS)
},
properties: factory.copyNecessaryProperties(object, [], true)
properties: props
};
},
};
Expand All @@ -110,6 +114,12 @@ OSDAnnotations.Convertor.register("geo-json", class extends OSDAnnotations.Conve
(object, geometry) => object.points = geometry.map(ring => this._toNativeRing(ring))),
"polyline": (object) => this._getAsNativeObject(object,
(object, geometry) => object.points = this._toNativeRing(geometry, false)),
"line": (object) => {
const factory = this.context.getAnnotationObjectFactory("line");
const params = factory.fromPointArray(object.geometry.coordinates, ([x, y]) => ({ x, y }));
const opts = $.extend({}, this.context.presets.getCommonProperties(), object.properties);
return factory.create(params, opts);
Comment on lines +118 to +121
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If the line factory is not registered or found, calling factory.fromPointArray will throw a TypeError. Add a guard to safely handle cases where the factory is missing.

const factory = this.context.getAnnotationObjectFactory("line");
if (!factory) return null;
const params = factory.fromPointArray(object.geometry.coordinates, ([x, y]) => ({ x, y }));
const opts = $.extend({}, this.context.presets.getCommonProperties(), object.properties);
return factory.create(params, opts);

},
"point": (object) => this._getAsNativeObject(object, (object, geometry) => {
//todo not necessary? left/top are already probably present in props
object.left = geometry[0];
Expand All @@ -122,7 +132,16 @@ OSDAnnotations.Convertor.register("geo-json", class extends OSDAnnotations.Conve
object.top = geometry[1];
return object;
}),
"ruler": (object) => this._getAsNativeObject(object),
"ruler": (object) => {
const factory = this.context.getAnnotationObjectFactory("ruler");
const params = factory.fromPointArray(object.geometry.coordinates, ([x, y]) => ({ x, y }));
const opts = $.extend({}, this.context.presets.getCommonProperties(), object.properties);
const obj = factory.create(params, opts);
Comment on lines +136 to +139
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If the ruler factory is not registered or found, calling factory.fromPointArray will throw a TypeError. Add a guard to safely handle cases where the factory is missing.

const factory = this.context.getAnnotationObjectFactory("ruler");
if (!factory) return null;
const params = factory.fromPointArray(object.geometry.coordinates, ([x, y]) => ({ x, y }));
const opts = $.extend({}, this.context.presets.getCommonProperties(), object.properties);
const obj = factory.create(params, opts);

if (object.properties?.text && obj?._objects?.[1]) {
obj._objects[1].set({ text: object.properties.text });
}
return obj;
},
};

// todo support unpacking like qupath does
Expand Down
32 changes: 30 additions & 2 deletions modules/annotations/convert/qupath.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ OSDAnnotations.Convertor.register("qupath", class extends OSDAnnotations.Convert
return res;
},
"polyline": (object, preset) => this._asGEOJsonFeature(object, preset, "LineString", ["points"], false),
"line": (object, preset) => this._asGEOJsonFeature(object, preset, "LineString", [], false),
"point": (object, preset) => {
object = this._asGEOJsonFeature(object, preset, "Point");
object.geometry.coordinates = object.geometry.coordinates[0] || [];
Expand All @@ -145,7 +146,10 @@ OSDAnnotations.Convertor.register("qupath", class extends OSDAnnotations.Convert
object.geometry.coordinates = object.geometry.coordinates[0] || [];
return object;
},
"ruler": (object, preset) => this._asGEOJsonFeature(object, preset, "LineString"),
"ruler": (object, preset) => {
object._objects = object.objects; // todo ugly, factory used underneath expects live group with _objects
return this._asGEOJsonFeature(object, preset, "LineString");
},
};

_decodeMulti(object, featureParentDict, type) {
Expand Down Expand Up @@ -229,6 +233,12 @@ OSDAnnotations.Convertor.register("qupath", class extends OSDAnnotations.Convert
let encoded = this.encoders[obj.factoryID]?.(obj, presets.find(p => p.presetID == obj.presetID));
if (encoded) {
encoded.type = "Feature";
encoded.properties = encoded.properties || {};
encoded.properties.xopatFactoryID = obj.factoryID;
if (obj.factoryID === "ruler") {
const txt = obj._objects?.[1]?.text;
if (txt) encoded.properties.xopatRulerText = txt;
}
if (this.options.serialize) encoded = JSON.stringify(encoded);
result.objects.push(encoded);
}
Expand Down Expand Up @@ -299,7 +309,25 @@ OSDAnnotations.Convertor.register("qupath", class extends OSDAnnotations.Convert
throw "Invalid feature! ";
}

let result = this.decoders[object.geometry.type]?.(object.geometry, object);
let result;
const xid = object.properties?.xopatFactoryID;
if (xid === "line" || xid === "ruler") {
const factory = this.context.getAnnotationObjectFactory(xid);
if (factory) {
const off = this.offset;
const deconv = off
? ([x, y]) => ({ x: x + off.x, y: y + off.y })
: ([x, y]) => ({ x, y });
const params = factory.fromPointArray(object.geometry.coordinates, deconv);
result = factory.create(params, this.context.presets.getCommonProperties());
if (xid === "ruler" && object.properties?.xopatRulerText && result?._objects?.[1]) {
result._objects[1].set({ text: object.properties.xopatRulerText });
}
}
}
if (!result) {
result = this.decoders[object.geometry.type]?.(object.geometry, object);
}

if (Array.isArray(result)) {
//MultiPolygon, MultiPoint, etc.
Expand Down
58 changes: 47 additions & 11 deletions modules/annotations/objectAdvancedFactories.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ OSDAnnotations.Ruler = class extends OSDAnnotations.AnnotationObjectFactory {
*/
configure(instance, options) {
if (instance.type === "group") {
// Legacy/native imports may lack color on the group; recover from preset
// so the inner line gets a stroke and renders.
if (!options.color && instance.presetID) {
const preset = this._presets?.get?.(instance.presetID);
if (preset?.color) options = $.extend({}, options, { color: preset.color });
}
this._configureParts(instance.item(0), instance.item(1), options);
this._configureWrapper(instance, instance.item(0), instance.item(1), options);
}
Expand Down Expand Up @@ -287,6 +293,9 @@ OSDAnnotations.Ruler = class extends OSDAnnotations.AnnotationObjectFactory {
}

const props = this._presets.getCommonProperties();
// Helper line was created from the active preset and carries its color;
// forward it so the group is serialized with color and the inner line keeps a stroke after import.
if (line.color) props.color = line.color;
obj = this._createWrap(obj, props);
obj.presetID = pid;
this._context.addAnnotation(obj);
Expand Down Expand Up @@ -331,18 +340,27 @@ OSDAnnotations.Ruler = class extends OSDAnnotations.AnnotationObjectFactory {
}

toPointArray(obj, converter, digits=undefined, quality=1) {
const line = obj._objects ? obj._objects[0] : [];

let x1 = line.x1;
let y1 = line.y1;
let x2 = line.x2;
let y2 = line.y2;
// Accept both live group (obj._objects) and serialized group (obj.objects).
const inner = obj._objects || obj.objects;
const line = inner ? inner[0] : null;
if (!line) return [];

// Inner line's x1..y2 are centered around the group's bbox center;
// compose with the group's left/top + half-extents to get absolute canvas coords.
const width = Number.isFinite(obj.width) ? obj.width : 0;
const height = Number.isFinite(obj.height) ? obj.height : 0;
const cx = (obj.left || 0) + width / 2;
const cy = (obj.top || 0) + height / 2;
let x1 = cx + (line.x1 || 0);
let y1 = cy + (line.y1 || 0);
let x2 = cx + (line.x2 || 0);
let y2 = cy + (line.y2 || 0);

if (digits !== undefined) {
x1 = parseFloat(x1.toFixed(digits));
y1 = parseFloat(y1.toFixed(digits));
x2 = parseFloat(x2.toFixed(digits));
y2 = parseFloat(y2.toFixed(digits));
x1 = parseFloat(Number(x1).toFixed(digits));
y1 = parseFloat(Number(y1).toFixed(digits));
x2 = parseFloat(Number(x2).toFixed(digits));
y2 = parseFloat(Number(y2).toFixed(digits));
}
return [converter(x1, y1), converter(x2, y2)];
}
Expand All @@ -360,6 +378,15 @@ OSDAnnotations.Ruler = class extends OSDAnnotations.AnnotationObjectFactory {
_configureLine(line, options) {
const lineOptions = Object.assign({}, options);
lineOptions.stroke = options.color;
// Don't carry the wrap group's positional/sizing props onto the inner line:
// the inner line's coords are local to the group and managed independently;
// applying the group's bbox here moves the line off-canvas (invisible).
delete lineOptions.left;
delete lineOptions.top;
delete lineOptions.width;
delete lineOptions.height;
delete lineOptions.scaleX;
delete lineOptions.scaleY;

$.extend(line, {
scaleX: 1,
Expand Down Expand Up @@ -414,8 +441,17 @@ OSDAnnotations.Ruler = class extends OSDAnnotations.AnnotationObjectFactory {
}

_createWrap(parts, options) {
const wrap = new fabric.Group(parts);
const line = parts[0];
const wrap = new fabric.Group(parts, {
originX: 'left',
originY: 'top',
left: line.left,
top: line.top,
width: line.width,
height: line.height,
});
this._configureWrapper(wrap, wrap.item(0), wrap.item(1), options);
wrap.setCoords();
return wrap;
}
};
Expand Down
Loading
Loading