Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Allow to mirror datasets #8485

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
[Commits](https://github.com/scalableminds/webknossos/compare/25.03.0...HEAD)

### Added
- Allow to mirror datasets along an axis in the dataset settings as part of the rotation feature. [#8485](https://github.com/scalableminds/webknossos/pull/8485)
- Added a credit system making payment for long running jobs possible. For now it is in testing phase. [#8352](https://github.com/scalableminds/webknossos/pull/8352)
- The maximum available storage of an organization is now enforced during upload. [#8385](https://github.com/scalableminds/webknossos/pull/8385)
- Performance improvements for volume annotation save requests. [#8460](https://github.com/scalableminds/webknossos/pull/8460)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Col, Form, type FormInstance, InputNumber, Row, Slider, Tooltip, Typography } from "antd";
import FormItem from "antd/es/form/FormItem";
import Checkbox, { type CheckboxChangeEvent } from "antd/lib/checkbox/Checkbox";
import {
AXIS_TO_TRANSFORM_INDEX,
EXPECTED_TRANSFORMATION_LENGTH,
IDENTITY_TRANSFORM,
type RotationAndMirroringSettings,
doAllLayersHaveTheSameRotation,
fromCenterToOrigin,
fromOriginToCenter,
getRotationMatrixAroundAxis,
transformationEqualsAffineIdentityTransform,
} from "oxalis/model/accessors/dataset_layer_transformation_accessor";
import BoundingBox from "oxalis/model/bucket_data_handling/bounding_box";
import { useCallback, useEffect, useMemo } from "react";
Expand Down Expand Up @@ -53,7 +56,11 @@ export const AxisRotationFormItem: React.FC<AxisRotationFormItemProps> = ({
) {
return;
}
const rotationValues = form.getFieldValue(["datasetRotation"]);
const rotationValues: {
x: RotationAndMirroringSettings;
y: RotationAndMirroringSettings;
z: RotationAndMirroringSettings;
} = form.getFieldValue(["datasetRotation"]);
const transformations = [
fromCenterToOrigin(datasetBoundingBox),
getRotationMatrixAroundAxis("x", rotationValues["x"]),
Expand All @@ -71,7 +78,7 @@ export const AxisRotationFormItem: React.FC<AxisRotationFormItemProps> = ({
}, [datasetBoundingBox, dataLayers, form]);

const setMatrixRotationsForAllLayer = useCallback(
(rotationInDegrees: number): void => {
(rotationInDegrees: number | undefined, isMirrored?: boolean): void => {
if (!form) {
return;
}
Expand All @@ -80,9 +87,16 @@ export const AxisRotationFormItem: React.FC<AxisRotationFormItemProps> = ({
if (datasetBoundingBox == null) {
return;
}

const rotationInRadians = rotationInDegrees * (Math.PI / 180);
const rotationMatrix = getRotationMatrixAroundAxis(axis, rotationInRadians);
const rotationValues: RotationAndMirroringSettings = {
...form.getFieldValue(["datasetRotation"])[axis],
};
if (rotationInDegrees !== undefined) {
rotationValues.rotationInDegrees = rotationInDegrees;
}
if (isMirrored !== undefined) {
rotationValues.isMirrored = isMirrored;
}
const rotationMatrix = getRotationMatrixAroundAxis(axis, rotationValues);
const dataLayersWithUpdatedTransforms: APIDataLayer[] = dataLayers.map((layer) => {
let transformations = layer.coordinateTransformations;
if (transformations == null || transformations.length !== EXPECTED_TRANSFORMATION_LENGTH) {
Expand All @@ -95,9 +109,13 @@ export const AxisRotationFormItem: React.FC<AxisRotationFormItemProps> = ({
];
}
transformations[AXIS_TO_TRANSFORM_INDEX[axis]] = rotationMatrix;
const updatedTransformations = transformationEqualsAffineIdentityTransform(transformations)
? null
: transformations;

return {
...layer,
coordinateTransformations: transformations,
coordinateTransformations: updatedTransformations,
};
});
form.setFieldValue(["dataSource", "dataLayers"], dataLayersWithUpdatedTransforms);
Expand All @@ -106,19 +124,19 @@ export const AxisRotationFormItem: React.FC<AxisRotationFormItemProps> = ({
);
return (
<Row gutter={24}>
<Col span={16}>
<Col span={8}>
<FormItemWithInfo
name={["datasetRotation", axis]}
name={["datasetRotation", axis, "rotationInDegrees"]}
label={`${axis.toUpperCase()} Axis Rotation`}
info={`Change the datasets rotation around the ${axis}-axis.`}
colon={false}
>
<Slider min={0} max={270} step={90} onChange={setMatrixRotationsForAllLayer} />
</FormItemWithInfo>
</Col>
<Col span={8} style={{ marginRight: -12 }}>
<Col span={4} style={{ marginRight: -12 }}>
<FormItem
name={["datasetRotation", axis]}
name={["datasetRotation", axis, "rotationInDegrees"]}
colon={false}
label=" " /* Whitespace label is needed for correct formatting*/
>
Expand All @@ -134,6 +152,20 @@ export const AxisRotationFormItem: React.FC<AxisRotationFormItemProps> = ({
/>
</FormItem>
</Col>
<Col span={4} style={{ marginRight: -12 }}>
<FormItem
name={["datasetRotation", axis, "isMirrored"]}
colon={false}
valuePropName="checked"
label={`Mirror ${axis.toUpperCase()} Axis`} /* Whitespace label is needed for correct formatting*/
>
<Checkbox
onChange={(evt: CheckboxChangeEvent) =>
setMatrixRotationsForAllLayer(undefined, evt.target.checked)
}
/>
</FormItem>
</Col>
</Row>
);
};
Expand All @@ -142,10 +174,10 @@ type AxisRotationSettingForDatasetProps = {
form: FormInstance | undefined;
};

export type DatasetRotation = {
x: number;
y: number;
z: number;
export type DatasetRotationAndMirroringSettings = {
x: RotationAndMirroringSettings;
y: RotationAndMirroringSettings;
z: RotationAndMirroringSettings;
};

export const AxisRotationSettingForDataset: React.FC<AxisRotationSettingForDatasetProps> = ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ function SimpleDatasetForm({
</Row>
<Row gutter={48}>
<Col span={24} xl={12} />
<Col span={24} xl={6}>
<Col span={24} xl={12}>
<AxisRotationSettingForDataset form={form} />
</Col>
</Row>
Expand Down
30 changes: 18 additions & 12 deletions frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { getReadableURLPart } from "oxalis/model/accessors/dataset_accessor";
import {
EXPECTED_TRANSFORMATION_LENGTH,
doAllLayersHaveTheSameRotation,
getRotationFromTransformationIn90DegreeSteps,
getRotationSettingsFromTransformationIn90DegreeSteps,
} from "oxalis/model/accessors/dataset_layer_transformation_accessor";
import type { DatasetConfiguration, OxalisState } from "oxalis/store";
import * as React from "react";
Expand All @@ -42,7 +42,7 @@ import type {
MutableAPIDataset,
} from "types/api_flow_types";
import { enforceValidatedDatasetViewConfiguration } from "types/schemas/dataset_view_configuration_defaults";
import type { DatasetRotation } from "./dataset_rotation_form_item";
import type { DatasetRotationAndMirroringSettings } from "./dataset_rotation_form_item";
import DatasetSettingsDataTab, { syncDataSourceFields } from "./dataset_settings_data_tab";
import DatasetSettingsDeleteTab from "./dataset_settings_delete_tab";
import DatasetSettingsMetadataTab from "./dataset_settings_metadata_tab";
Expand Down Expand Up @@ -82,7 +82,7 @@ export type FormData = {
dataset: APIDataset;
defaultConfiguration: DatasetConfiguration;
defaultConfigurationLayersJson: string;
datasetRotation?: DatasetRotation;
datasetRotation?: DatasetRotationAndMirroringSettings;
};

class DatasetSettingsView extends React.PureComponent<PropsWithFormAndRouter, State> {
Expand Down Expand Up @@ -204,22 +204,28 @@ class DatasetSettingsView extends React.PureComponent<PropsWithFormAndRouter, St
// Retrieve the initial dataset rotation settings from the data source config.
if (doAllLayersHaveTheSameRotation(dataSource.dataLayers)) {
const firstLayerTransformations = dataSource.dataLayers[0].coordinateTransformations;
let initialDatasetRotationSettings: DatasetRotation;
let initialDatasetRotationSettings: DatasetRotationAndMirroringSettings;
if (
!firstLayerTransformations ||
firstLayerTransformations.length !== EXPECTED_TRANSFORMATION_LENGTH
) {
initialDatasetRotationSettings = {
x: 0,
y: 0,
z: 0,
};
const nulledSetting = { rotationInDegrees: 0, isMirrored: false };
initialDatasetRotationSettings = { x: nulledSetting, y: nulledSetting, z: nulledSetting };
} else {
initialDatasetRotationSettings = {
// First transformation is a translation to the coordinate system origin.
x: getRotationFromTransformationIn90DegreeSteps(firstLayerTransformations[1], "x"),
y: getRotationFromTransformationIn90DegreeSteps(firstLayerTransformations[2], "y"),
z: getRotationFromTransformationIn90DegreeSteps(firstLayerTransformations[3], "z"),
x: getRotationSettingsFromTransformationIn90DegreeSteps(
firstLayerTransformations[1],
"x",
),
y: getRotationSettingsFromTransformationIn90DegreeSteps(
firstLayerTransformations[2],
"y",
),
z: getRotationSettingsFromTransformationIn90DegreeSteps(
firstLayerTransformations[3],
"z",
),
// Fifth transformation is a translation back to the original position.
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,23 @@ export function flatToNestedMatrix(matrix: Matrix4x4): NestedMatrix4 {
];
}

// This function extracts the rotation in 90 degree steps a the transformation matrix.
const axisPositionInMatrix = { x: 0, y: 1, z: 2 };

export type RotationAndMirroringSettings = {
rotationInDegrees: number;
isMirrored: boolean;
};
// This function extracts the rotation in 90 degree steps and whether the axis is mirrored from the transformation matrix.
// The transformation matrix must only include a rotation around one of the main axis.
export function getRotationFromTransformationIn90DegreeSteps(
export function getRotationSettingsFromTransformationIn90DegreeSteps(
transformation: CoordinateTransformation | undefined,
axis: "x" | "y" | "z",
) {
): RotationAndMirroringSettings {
if (transformation && transformation.type !== "affine") {
return 0;
return { rotationInDegrees: 0, isMirrored: false };
}
const matrix = transformation ? transformation.matrix : IDENTITY_MATRIX;
const isMirrored = matrix[axisPositionInMatrix[axis]][axisPositionInMatrix[axis]] < 0;
const cosineLocation = cosineLocationOfRotationInMatrix[axis];
const sinusLocation = sinusLocationOfRotationInMatrix[axis];
const sinOfAngle = matrix[sinusLocation[0]][sinusLocation[1]];
Expand All @@ -94,7 +101,7 @@ export function getRotationFromTransformationIn90DegreeSteps(
const rotationInDegrees = rotation * (180 / Math.PI);
// Round to multiple of 90 degrees and keep the result positive.
const roundedRotation = mod(Math.round((rotationInDegrees + 360) / 90) * 90, 360);
return roundedRotation;
return { rotationInDegrees: roundedRotation, isMirrored };
}

export function fromCenterToOrigin(bbox: BoundingBox): AffineTransformation {
Expand All @@ -112,13 +119,23 @@ export function fromOriginToCenter(bbox: BoundingBox): AffineTransformation {
.transpose(); // Column-major to row-major
return { type: "affine", matrix: flatToNestedMatrix(translationMatrix.toArray()) };
}

export function getRotationMatrixAroundAxis(
axis: "x" | "y" | "z",
angleInRadians: number,
rotationAndMirroringSettings: RotationAndMirroringSettings,
): AffineTransformation {
const euler = new THREE.Euler();
euler[axis] = angleInRadians;
const rotationMatrix = new THREE.Matrix4().makeRotationFromEuler(euler).transpose(); // Column-major to row-major
const rotationInRadians = rotationAndMirroringSettings.rotationInDegrees * (Math.PI / 180);
euler[axis] = rotationInRadians;
let rotationMatrix = new THREE.Matrix4().makeRotationFromEuler(euler);
if (rotationAndMirroringSettings.isMirrored) {
const scaleVector = new THREE.Vector3(1, 1, 1);
scaleVector[axis] = -1;
rotationMatrix = rotationMatrix.multiply(
new THREE.Matrix4().makeScale(scaleVector.x, scaleVector.y, scaleVector.z),
);
}
rotationMatrix = rotationMatrix.transpose(); // Column-major to row-major
const matrixWithoutNearlyZeroValues = rotationMatrix
.toArray()
// Avoid nearly zero values due to floating point arithmetic inaccuracies.
Expand Down Expand Up @@ -364,15 +381,18 @@ function isTranslationOnly(transformation?: AffineTransformation) {
return scale.equals(NON_SCALED_VECTOR) && quaternion.equals(IDENTITY_QUATERNION);
}

function isRotationOnly(transformation?: AffineTransformation) {
function isOnlyRotatedOrMirrored(transformation?: AffineTransformation) {
if (!transformation) {
return false;
}
const threeMatrix = new THREE.Matrix4()
.fromArray(nestedToFlatMatrix(transformation.matrix))
.transpose();
threeMatrix.decompose(translation, quaternion, scale);
return translation.length() === 0 && scale.equals(NON_SCALED_VECTOR);
return (
translation.length() === 0 &&
_.isEqual([Math.abs(scale.x), Math.abs(scale.y), Math.abs(scale.z)], [1, 1, 1])
);
}

function hasValidTransformationCount(dataLayers: Array<APIDataLayer>): boolean {
Expand All @@ -387,19 +407,19 @@ function hasOnlyAffineTransformations(dataLayers: Array<APIDataLayer>): boolean

// The transformation array consists of 5 matrices:
// 1. Translation to coordinate system origin
// 2. Rotation around x-axis
// 3. Rotation around y-axis
// 4. Rotation around z-axis
// 2. Rotation around x-axis (potentially mirrored)
// 3. Rotation around y-axis (potentially mirrored)
// 4. Rotation around z-axis (potentially mirrored)
// 5. Translation back to original position
export const EXPECTED_TRANSFORMATION_LENGTH = 5;

function hasValidTransformationPattern(transformations: CoordinateTransformation[]): boolean {
return (
transformations.length === EXPECTED_TRANSFORMATION_LENGTH &&
isTranslationOnly(transformations[0] as AffineTransformation) &&
isRotationOnly(transformations[1] as AffineTransformation) &&
isRotationOnly(transformations[2] as AffineTransformation) &&
isRotationOnly(transformations[3] as AffineTransformation) &&
isOnlyRotatedOrMirrored(transformations[1] as AffineTransformation) &&
isOnlyRotatedOrMirrored(transformations[2] as AffineTransformation) &&
isOnlyRotatedOrMirrored(transformations[3] as AffineTransformation) &&
isTranslationOnly(transformations[4] as AffineTransformation)
);
}
Expand Down Expand Up @@ -443,6 +463,27 @@ function _doAllLayersHaveTheSameRotation(dataLayers: Array<APIDataLayer>): boole

export const doAllLayersHaveTheSameRotation = _.memoize(_doAllLayersHaveTheSameRotation);

export function transformationEqualsAffineIdentityTransform(
transformations: CoordinateTransformation[],
): boolean {
const hasValidTransformationCount = transformations.length === EXPECTED_TRANSFORMATION_LENGTH;
const hasOnlyAffineTransformations = transformations.every(
(transformation) => transformation.type === "affine",
);
if (!hasValidTransformationCount || !hasOnlyAffineTransformations) {
return false;
}
const resultingTransformation = transformations.reduce(
(accTransformation, currentTransformation) =>
chainTransforms(
accTransformation,
createAffineTransformFromMatrix(currentTransformation.matrix),
),
IdentityTransform as Transform,
);
return _.isEqual(resultingTransformation, IdentityTransform);
}

export function globalToLayerTransformedPosition(
globalPos: Vector3,
layerName: string,
Expand Down