optica-core 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -6
- package/src/components/ui/app-menu/AppMenu.vue +1 -1
- package/src/components/ui/credits/Credits.vue +1 -3
- package/src/components/ui/distance/DistanceIteration.vue +14 -18
- package/src/components/ui/distance/DistanceList.vue +9 -12
- package/src/components/ui/distance/types.ts +8 -2
- package/src/components/ui/image-viewer/ImageViewer.vue +9 -3
- package/src/components/ui/image-viewer/types.ts +0 -2
- package/src/components/ui/landmark/LandmarkIteration.vue +19 -12
- package/src/components/ui/landmark/LandmarkList.vue +1 -1
- package/src/components/ui/landmark/types.ts +0 -4
- package/src/components/ui/project-loader/ProjectLoader.vue +21 -18
- package/src/config/appSettings.ts +0 -4
- package/src/data/models/annotations.ts +21 -9
- package/src/data/models/coordinates.ts +14 -10
- package/src/data/models/dequeMax2.ts +57 -19
- package/src/data/models/distance.ts +17 -29
- package/src/data/models/image.ts +3 -1
- package/src/data/models/landmark.ts +0 -35
- package/src/data/models/project.ts +0 -4
- package/src/data/providers/backend.ts +28 -0
- package/src/data/repositories/data_repository.ts +7 -6
- package/src/data/repositories/project_repository.ts +6 -4
- package/src/data/repositories/repository.ts +3 -2
- package/src/env.d.ts +0 -1
- package/src/index.ts +2 -3
- package/src/lib/interaction.ts +10 -0
- package/src/lib/persistence.ts +10 -5
- package/src/lib/utils.ts +6 -8
- package/tests/annotations.test.ts +65 -4
- package/tests/project.test.ts +21 -10
- package/src/data/models/marker.ts +0 -8
- package/src/data/providers/provider_factory.ts +0 -32
- package/src/data/repositories/repository_factory.ts +0 -31
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Color from "color"
|
|
2
2
|
import { Landmark, type LandmarkObject } from "./landmark"
|
|
3
|
-
import { distanceVector, type
|
|
3
|
+
import { distanceVector, type Position } from "./coordinates"
|
|
4
4
|
import { computeDistance, randomColorRGB, type ScaleUnit } from "../../lib/utils"
|
|
5
5
|
|
|
6
6
|
export type DistanceObject = {
|
|
@@ -9,52 +9,40 @@ export type DistanceObject = {
|
|
|
9
9
|
color: string,
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// sum of the segments between consecutive landmarks, `point`: where a landmark is. undefined under two landmarks
|
|
13
|
+
function sumOfSegments(landmarks: Landmark[], point: (landmark: Landmark) => Position) : number | undefined {
|
|
14
|
+
if(landmarks.length < 2 ){
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
return computeDistance(landmarks.slice(1).map((landmark, i) => distanceVector(point(landmarks[i]!), point(landmark))))
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
export class Distance {
|
|
13
21
|
label : string
|
|
14
22
|
landmarks : Array<Landmark>
|
|
15
23
|
color: Color
|
|
16
|
-
edit_label: boolean
|
|
17
|
-
edit_distance: boolean
|
|
18
24
|
show : boolean
|
|
19
25
|
|
|
20
26
|
constructor(label: string, landmarks : Array<Landmark> | null = null, color: Color | null = null){
|
|
21
27
|
this.label = label
|
|
22
28
|
this.landmarks = landmarks || new Array()
|
|
23
|
-
this.edit_label = false
|
|
24
|
-
this.edit_distance = false
|
|
25
29
|
this.show = true
|
|
26
30
|
this.color = color || Color.rgb(randomColorRGB())
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
//
|
|
30
|
-
get distance() :
|
|
31
|
-
|
|
32
|
-
return undefined
|
|
33
|
-
}
|
|
34
|
-
let distance : Position[] = new Array()
|
|
35
|
-
for(let i = 1; i < this.landmarks.length; i++){
|
|
36
|
-
distance.push(distanceVector(this.landmarks[i-1].position, this.landmarks[i].position))
|
|
37
|
-
}
|
|
38
|
-
return distance
|
|
33
|
+
// physical length: sum of the segments between consecutive landmarks, undefined under two landmarks
|
|
34
|
+
get distance() : number | undefined {
|
|
35
|
+
return sumOfSegments(this.landmarks, (landmark) => landmark.position)
|
|
39
36
|
}
|
|
40
37
|
|
|
41
|
-
//
|
|
42
|
-
get distancePx() :
|
|
43
|
-
|
|
44
|
-
return undefined
|
|
45
|
-
}
|
|
46
|
-
let distance : Coordinates[] = new Array()
|
|
47
|
-
for(let i = 1; i < this.landmarks.length; i++){
|
|
48
|
-
distance.push(distanceVector(this.landmarks[i-1].pose.marker, this.landmarks[i].pose.marker))
|
|
49
|
-
}
|
|
50
|
-
return distance
|
|
38
|
+
// pixel length: sum of the segments between consecutive markers, undefined under two landmarks
|
|
39
|
+
get distancePx() : number | undefined {
|
|
40
|
+
return sumOfSegments(this.landmarks, (landmark) => landmark.pose.marker)
|
|
51
41
|
}
|
|
52
42
|
|
|
53
|
-
// length in image units: pixel length for the "px" scale, physical length otherwise
|
|
54
|
-
// undefined while it can't be measured
|
|
43
|
+
// length in image units: pixel length for the "px" scale, physical length otherwise
|
|
55
44
|
length(scale?: ScaleUnit) : number | undefined {
|
|
56
|
-
|
|
57
|
-
return intervals === undefined ? undefined : computeDistance(intervals)
|
|
45
|
+
return scale == "px" ? this.distancePx : this.distance
|
|
58
46
|
}
|
|
59
47
|
|
|
60
48
|
in(landmark : Landmark | string) : boolean{
|
package/src/data/models/image.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Minimal image identity shared by every app (stack, rotation, spectral, ...)
|
|
2
2
|
export type ImageName = {
|
|
3
3
|
name: string,
|
|
4
|
-
label: string
|
|
4
|
+
label: string,
|
|
5
|
+
// sent by the backend when the thumbnail of this image exists
|
|
6
|
+
hasThumbnail?: boolean
|
|
5
7
|
}
|
|
6
8
|
|
|
7
9
|
// What an annotation keeps of its image: the name to find it back, the label and the
|
|
@@ -23,9 +23,6 @@ export class Landmark {
|
|
|
23
23
|
position : Position
|
|
24
24
|
color: Color
|
|
25
25
|
show: boolean
|
|
26
|
-
edit: boolean
|
|
27
|
-
isClicked: boolean
|
|
28
|
-
isDragged: boolean
|
|
29
26
|
|
|
30
27
|
// pose.image may be the full image, only its reference is kept
|
|
31
28
|
constructor(id: string, label: string, pose: Pose, position : Position, color: Color | null = null) {
|
|
@@ -33,11 +30,8 @@ export class Landmark {
|
|
|
33
30
|
this.label = label
|
|
34
31
|
this.pose = { marker: pose.marker, image: imageRef(pose.image) }
|
|
35
32
|
this.position = position
|
|
36
|
-
this.edit = false
|
|
37
33
|
this.color = color || Color.rgb(randomColorRGB())
|
|
38
34
|
this.show = true
|
|
39
|
-
this.isClicked = false
|
|
40
|
-
this.isDragged = false
|
|
41
35
|
}
|
|
42
36
|
|
|
43
37
|
equals(other : Landmark | string | null | undefined){
|
|
@@ -58,16 +52,6 @@ export class Landmark {
|
|
|
58
52
|
return { id: this.id, label: this.label, pose: this.pose, position : this.position, color: this.color.hex() }
|
|
59
53
|
}
|
|
60
54
|
|
|
61
|
-
getId() : string {
|
|
62
|
-
return this.id
|
|
63
|
-
}
|
|
64
|
-
getLabel() : string {
|
|
65
|
-
return this.label
|
|
66
|
-
}
|
|
67
|
-
setLabel(label : string){
|
|
68
|
-
this.label = label
|
|
69
|
-
}
|
|
70
|
-
|
|
71
55
|
getColorHEX() : string{
|
|
72
56
|
return this.color.hex()
|
|
73
57
|
}
|
|
@@ -87,23 +71,4 @@ export class Landmark {
|
|
|
87
71
|
image : imageRef(image)
|
|
88
72
|
}
|
|
89
73
|
}
|
|
90
|
-
|
|
91
|
-
getPose() : Pose {
|
|
92
|
-
return this.pose
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
setPosition(position : Position) {
|
|
96
|
-
this.position = position
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
getPosition() : Position {
|
|
100
|
-
return this.position
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
getEdit() : boolean{
|
|
104
|
-
return this.edit
|
|
105
|
-
}
|
|
106
|
-
setEdit(edit : boolean){
|
|
107
|
-
this.edit = edit
|
|
108
|
-
}
|
|
109
74
|
}
|
|
@@ -11,7 +11,6 @@ export type AcquisitionObject<TImage extends ProjectImage = ProjectImage> = {
|
|
|
11
11
|
// images on their own: the stacked results, the depth maps...
|
|
12
12
|
standaloneImages?: TImage[]
|
|
13
13
|
size: Size
|
|
14
|
-
thumbnails: boolean
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
// Images taken together, with the viewer state and the annotations of the acquisition.
|
|
@@ -22,7 +21,6 @@ export class Acquisition<TImage extends ProjectImage = ProjectImage, TAnnotation
|
|
|
22
21
|
images: TImage[]
|
|
23
22
|
standaloneImages: TImage[]
|
|
24
23
|
size: Size
|
|
25
|
-
thumbnails: boolean
|
|
26
24
|
camera: Camera
|
|
27
25
|
// shared by the images of the group and the standalone ones
|
|
28
26
|
annotations: TAnnotations
|
|
@@ -37,7 +35,6 @@ export class Acquisition<TImage extends ProjectImage = ProjectImage, TAnnotation
|
|
|
37
35
|
this.images = object.images
|
|
38
36
|
this.standaloneImages = object.standaloneImages ?? []
|
|
39
37
|
this.size = object.size
|
|
40
|
-
this.thumbnails = object.thumbnails
|
|
41
38
|
this.camera = createCamera()
|
|
42
39
|
this.annotations = this.createAnnotations()
|
|
43
40
|
this.index = 0
|
|
@@ -121,7 +118,6 @@ export class Acquisition<TImage extends ProjectImage = ProjectImage, TAnnotation
|
|
|
121
118
|
images: this.images,
|
|
122
119
|
standaloneImages: this.standaloneImages,
|
|
123
120
|
size: this.size,
|
|
124
|
-
thumbnails: this.thumbnails,
|
|
125
121
|
}
|
|
126
122
|
}
|
|
127
123
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DataProvider } from "./providers";
|
|
2
|
+
import { WebProvider } from "./web_providers";
|
|
3
|
+
import { OrthancProvider } from "./orthanc_providers";
|
|
4
|
+
import { providerSettings } from "../../config/appSettings"
|
|
5
|
+
|
|
6
|
+
// Backends an app can be served by (VITE_BACKEND_TYPE)
|
|
7
|
+
export type BackendType = "WEB" | "ORTHANC"
|
|
8
|
+
|
|
9
|
+
// how to build the provider of each backend from its url (VITE_BACKEND_URL)
|
|
10
|
+
export type ProviderBuilders<P extends DataProvider> = Record<BackendType, (url: string) => P>
|
|
11
|
+
|
|
12
|
+
// The core providers, `plugin`: the path of the app in Orthanc
|
|
13
|
+
export function coreProviders(plugin: string): ProviderBuilders<DataProvider> {
|
|
14
|
+
return {
|
|
15
|
+
WEB: (url) => new WebProvider(url),
|
|
16
|
+
ORTHANC: (url) => new OrthancProvider(url, plugin),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Provider of the backend set in the environment (.env: VITE_BACKEND_TYPE, VITE_BACKEND_URL).
|
|
21
|
+
// Apps with their own routes pass their own providers (sphaer)
|
|
22
|
+
export function createProvider<P extends DataProvider>(builders: ProviderBuilders<P>): P {
|
|
23
|
+
const type = providerSettings.type
|
|
24
|
+
if (!(type in builders)) {
|
|
25
|
+
throw new Error(`Unknown VITE_BACKEND_TYPE "${type}", expected one of ${Object.keys(builders).join(", ")}`)
|
|
26
|
+
}
|
|
27
|
+
return builders[type as BackendType](providerSettings.url)
|
|
28
|
+
}
|
|
@@ -5,7 +5,7 @@ import type { ImageName, ProjectImage } from "../models/image";
|
|
|
5
5
|
import type { Pose } from "../models/landmark";
|
|
6
6
|
|
|
7
7
|
// Common part of every app repository, apps only have to parse their /images payload
|
|
8
|
-
export abstract class DataRepository<TData> implements Repository<TData> {
|
|
8
|
+
export abstract class DataRepository<TData, TPosition extends Position = Position> implements Repository<TData, TPosition> {
|
|
9
9
|
provider: DataProvider;
|
|
10
10
|
|
|
11
11
|
constructor(provider: DataProvider) {
|
|
@@ -14,9 +14,9 @@ export abstract class DataRepository<TData> implements Repository<TData> {
|
|
|
14
14
|
|
|
15
15
|
abstract getImages(objectPath: string): Promise<TData>
|
|
16
16
|
|
|
17
|
-
computeLandmarkPosition(objectPath: string, pose: Pose) : Promise<
|
|
17
|
+
computeLandmarkPosition(objectPath: string, pose: Pose) : Promise<TPosition> {
|
|
18
18
|
return this.provider.computeLandmarkPosition(objectPath, pose.image.name, pose.marker).then((res) => {
|
|
19
|
-
return res.data as
|
|
19
|
+
return res.data as TPosition
|
|
20
20
|
})
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -28,11 +28,12 @@ export abstract class DataRepository<TData> implements Repository<TData> {
|
|
|
28
28
|
return this.provider.getThumbnail(objectPath, imageName)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
// Fill the image and thumbnail urls of images sent by /images
|
|
32
|
-
|
|
31
|
+
// Fill the image and thumbnail urls of images sent by /images,
|
|
32
|
+
// the thumbnail url only for the images the backend marks with `hasThumbnail`
|
|
33
|
+
protected resolveUrls<T extends ImageName>(objectPath: string, images: T[]): (T & ProjectImage)[] {
|
|
33
34
|
return images.map((image) => Object.assign(image, {
|
|
34
35
|
image: this.getFullImage(objectPath, image.name),
|
|
35
|
-
thumbnail:
|
|
36
|
+
thumbnail: image.hasThumbnail ? this.getThumbnail(objectPath, image.name) : undefined,
|
|
36
37
|
}))
|
|
37
38
|
}
|
|
38
39
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DataProvider } from "../providers/providers";
|
|
2
|
+
import type { Position } from "../models/coordinates";
|
|
2
3
|
import type { ProjectImage } from "../models/image";
|
|
3
4
|
import { Acquisition, Project, type AcquisitionObject } from "../models/project";
|
|
4
5
|
import type { AnnotationsSet } from "../models/annotations";
|
|
@@ -16,8 +17,9 @@ export function parseAcquisitions<TImage extends ProjectImage>(data: any): Acqui
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
// Loads /images as a Project, apps whose payload differs pass a parser
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// TPosition: Coords3D when the backend gives 3D positions (stack, depth)
|
|
21
|
+
export class ProjectRepository<TImage extends ProjectImage = ProjectImage, TAcquisition extends Acquisition<TImage, AnnotationsSet> = Acquisition<TImage>, TPosition extends Position = Position>
|
|
22
|
+
extends DataRepository<Project<TImage, TAcquisition>, TPosition> {
|
|
21
23
|
parse: ProjectParser<TImage>
|
|
22
24
|
createAcquisition: AcquisitionFactory<TImage, TAcquisition>
|
|
23
25
|
|
|
@@ -34,8 +36,8 @@ export class ProjectRepository<TImage extends ProjectImage = ProjectImage, TAcqu
|
|
|
34
36
|
async getImages(objectPath: string): Promise<Project<TImage, TAcquisition>> {
|
|
35
37
|
const res = await this.provider.getImages(objectPath)
|
|
36
38
|
const acquisitions = this.parse(res.data).map((object) => {
|
|
37
|
-
object.images = this.resolveUrls(objectPath, object.images
|
|
38
|
-
object.standaloneImages = this.resolveUrls(objectPath, object.standaloneImages ?? []
|
|
39
|
+
object.images = this.resolveUrls(objectPath, object.images)
|
|
40
|
+
object.standaloneImages = this.resolveUrls(objectPath, object.standaloneImages ?? [])
|
|
39
41
|
return this.createAcquisition(object)
|
|
40
42
|
})
|
|
41
43
|
return new Project<TImage, TAcquisition>(objectPath, acquisitions)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Position } from "../models/coordinates";
|
|
2
2
|
import type { Pose } from "../models/landmark";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// TPosition: Coords3D for the apps whose backend gives 3D positions (stack, depth)
|
|
5
|
+
export interface Repository<TData = unknown, TPosition extends Position = Position> {
|
|
5
6
|
getImages : (objectPath:string) => Promise<TData>;
|
|
6
7
|
getFullImage : (objectPath:string, imageName : string) => string;
|
|
7
8
|
getThumbnail : (objectPath:string, imageName : string) => string;
|
|
8
|
-
computeLandmarkPosition : (objectPath:string, pose : Pose) => Promise<
|
|
9
|
+
computeLandmarkPosition : (objectPath:string, pose : Pose) => Promise<TPosition>;
|
|
9
10
|
}
|
package/src/env.d.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
export * from "./lib/utils"
|
|
2
2
|
export * from "./lib/export"
|
|
3
3
|
export * from "./lib/persistence"
|
|
4
|
+
export * from "./lib/interaction"
|
|
4
5
|
export * from "./config/appSettings"
|
|
5
6
|
|
|
6
7
|
export * from "./data/models/coordinates"
|
|
7
8
|
export * from "./data/models/image"
|
|
8
9
|
export * from "./data/models/camera"
|
|
9
10
|
export * from "./data/models/project"
|
|
10
|
-
export * from "./data/models/marker"
|
|
11
11
|
export * from "./data/models/landmark"
|
|
12
12
|
export * from "./data/models/distance"
|
|
13
13
|
export * from "./data/models/dequeMax2"
|
|
@@ -17,9 +17,8 @@ export * from "./data/providers/routes"
|
|
|
17
17
|
export * from "./data/providers/providers"
|
|
18
18
|
export * from "./data/providers/web_providers"
|
|
19
19
|
export * from "./data/providers/orthanc_providers"
|
|
20
|
-
export * from "./data/providers/
|
|
20
|
+
export * from "./data/providers/backend"
|
|
21
21
|
|
|
22
22
|
export * from "./data/repositories/repository"
|
|
23
23
|
export * from "./data/repositories/data_repository"
|
|
24
24
|
export * from "./data/repositories/project_repository"
|
|
25
|
-
export * from "./data/repositories/repository_factory"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { reactive } from "vue"
|
|
2
|
+
|
|
3
|
+
// What the user is doing with the landmarks right now, shared by the lists and the ImageViewer.
|
|
4
|
+
// Not part of the models: never saved, one landmark at a time (by id).
|
|
5
|
+
export const interaction = reactive({
|
|
6
|
+
// pressed in a list, until the pointer is released: the viewer draws it bigger
|
|
7
|
+
pressed: null as string | null,
|
|
8
|
+
// dragged in the viewer: its row in the lists is highlighted and scrolled to
|
|
9
|
+
dragged: null as string | null,
|
|
10
|
+
})
|
package/src/lib/persistence.ts
CHANGED
|
@@ -34,17 +34,22 @@ export function usePersistedAnnotations(project: () => Project<any, any> | null
|
|
|
34
34
|
}
|
|
35
35
|
}, { immediate: true })
|
|
36
36
|
|
|
37
|
-
//
|
|
37
|
+
// Watches what is saved, serialized: only a change of it writes the storage, not the
|
|
38
|
+
// display state of the annotations (hover, drag) nor the images of the project.
|
|
38
39
|
// Registered after the restore: it saves the restored annotations, never empty ones over them.
|
|
39
|
-
watch(() =>
|
|
40
|
+
watch(() => {
|
|
40
41
|
const loaded = project()
|
|
41
42
|
if (!loaded) {
|
|
42
|
-
return
|
|
43
|
+
return undefined
|
|
43
44
|
}
|
|
44
45
|
const saved: SavedAnnotations = {
|
|
45
46
|
annotations: loaded.annotationsToJSON(),
|
|
46
47
|
selection: Object.fromEntries(loaded.acquisitions.map((acquisition) => [acquisition.id, acquisition.annotations.getSelection()])),
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
-
},
|
|
49
|
+
return { objectPath: loaded.objectPath, json: JSON.stringify(saved) }
|
|
50
|
+
}, (saved, previous) => {
|
|
51
|
+
if (saved && (saved.objectPath != previous?.objectPath || saved.json != previous.json)) {
|
|
52
|
+
storage.setItem(key(saved.objectPath), saved.json)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
50
55
|
}
|
package/src/lib/utils.ts
CHANGED
|
@@ -31,9 +31,12 @@ export function randomColorRGB(): [number, number, number] {
|
|
|
31
31
|
return [Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), Math.floor(Math.random() * 256)]
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
const ID_CHARS = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
35
|
+
|
|
36
|
+
// random base-36 id of `length` characters (landmarks, also their first label: kept short).
|
|
37
|
+
// getRandomValues works on http too, unlike crypto.randomUUID
|
|
38
|
+
export function randomID(length: number = 5): string {
|
|
39
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => ID_CHARS[byte % ID_CHARS.length]).join("")
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export enum Scale {
|
|
@@ -63,11 +66,6 @@ export const DOT_RADIUS = 4.5
|
|
|
63
66
|
export const SPACE_TARGET = 0.2
|
|
64
67
|
export const ROUNDING = 2
|
|
65
68
|
|
|
66
|
-
// length in image units -> length shown in `scale`, corrected by the calibration factor
|
|
67
|
-
export function toScale(length: number, adjustFactor: number): number {
|
|
68
|
-
return length * adjustFactor
|
|
69
|
-
}
|
|
70
|
-
|
|
71
69
|
// calibration factor so that `length` is shown as `target` in `scale`
|
|
72
70
|
export function adjustFactorFor(length: number, target: number, scale: ScaleUnit): number {
|
|
73
71
|
return target / length * Scale[scale]
|
|
@@ -2,14 +2,14 @@ import { describe, expect, it } from "vitest"
|
|
|
2
2
|
import { effectScope, isReactive, nextTick, reactive, shallowRef } from "vue"
|
|
3
3
|
import Color from "color"
|
|
4
4
|
import {
|
|
5
|
-
Acquisition, Annotations, Distance, Landmark, Project, usePersistedAnnotations,
|
|
5
|
+
Acquisition, Annotations, DequeMax2, Distance, Landmark, Project, usePersistedAnnotations,
|
|
6
6
|
type ProjectImage,
|
|
7
7
|
} from "../src"
|
|
8
8
|
|
|
9
9
|
const size = { width: 100, height: 100 }
|
|
10
10
|
const image = (name: string): ProjectImage => ({ name, label: name, image: "" })
|
|
11
11
|
const landmark = (id: string, img = image("i0")) => new Landmark(id, id, { marker: { x: 1, y: 2 }, image: img }, { x: 10, y: 20 }, Color("#ff0000"))
|
|
12
|
-
const acquisition = (id: string, names: string[] = ["i0"]) => new Acquisition({ id, images: names.map(image), size
|
|
12
|
+
const acquisition = (id: string, names: string[] = ["i0"]) => new Acquisition({ id, images: names.map(image), size })
|
|
13
13
|
|
|
14
14
|
describe("Annotations", () => {
|
|
15
15
|
it("creates a selected distance and returns the reactive copy through a store", () => {
|
|
@@ -88,9 +88,9 @@ class TaggedAcquisition extends Acquisition<ProjectImage> {
|
|
|
88
88
|
|
|
89
89
|
describe("Project annotations", () => {
|
|
90
90
|
it("lets an app use its own Annotations", () => {
|
|
91
|
-
const a = new TaggedAcquisition({ id: "a", images: [image("i0")], size
|
|
91
|
+
const a = new TaggedAcquisition({ id: "a", images: [image("i0")], size })
|
|
92
92
|
expect(a.annotations.tagged).toBe(true)
|
|
93
|
-
a.annotations.restore({ distances: [{ label: "d", color: "#00ff00", landmarks: [],
|
|
93
|
+
a.annotations.restore({ distances: [{ label: "d", color: "#00ff00", landmarks: [], distance: 0 }] })
|
|
94
94
|
expect(a.annotations.distances[0]).toBeInstanceOf(Distance)
|
|
95
95
|
})
|
|
96
96
|
|
|
@@ -170,6 +170,28 @@ describe("usePersistedAnnotations", () => {
|
|
|
170
170
|
scope2.stop()
|
|
171
171
|
})
|
|
172
172
|
|
|
173
|
+
it("doesn't write the storage for what isn't saved", async () => {
|
|
174
|
+
const storage = new MemoryStorage()
|
|
175
|
+
const current = shallowRef<Project | null>(null)
|
|
176
|
+
const scope = effectScope()
|
|
177
|
+
scope.run(() => usePersistedAnnotations(() => current.value, storage))
|
|
178
|
+
current.value = reactive(new Project("A1", [acquisition("rotation")])) as Project
|
|
179
|
+
await nextTick()
|
|
180
|
+
const annotations = current.value.acquisition!.annotations
|
|
181
|
+
annotations.landmarks.push(landmark("kept"))
|
|
182
|
+
await nextTick()
|
|
183
|
+
let writes = 0
|
|
184
|
+
const setItem = storage.setItem.bind(storage)
|
|
185
|
+
storage.setItem = (key, value) => { writes++; setItem(key, value) }
|
|
186
|
+
annotations.landmarks[0]!.show = false
|
|
187
|
+
await nextTick()
|
|
188
|
+
expect(writes).toBe(0)
|
|
189
|
+
annotations.landmarks[0]!.label = "renamed"
|
|
190
|
+
await nextTick()
|
|
191
|
+
expect(writes).toBe(1)
|
|
192
|
+
scope.stop()
|
|
193
|
+
})
|
|
194
|
+
|
|
173
195
|
it("ignores a saved selection that doesn't fit the annotations", () => {
|
|
174
196
|
const annotations = new Annotations()
|
|
175
197
|
annotations.newDistance()
|
|
@@ -179,3 +201,42 @@ describe("usePersistedAnnotations", () => {
|
|
|
179
201
|
expect(annotations.selectedDistanceIndex).toBe(-1)
|
|
180
202
|
})
|
|
181
203
|
})
|
|
204
|
+
|
|
205
|
+
describe("scale", () => {
|
|
206
|
+
const distance = () => new Distance("d", [
|
|
207
|
+
new Landmark("a", "a", { marker: { x: 0, y: 0 }, image: image("i0") }, { x: 0, y: 0 }),
|
|
208
|
+
new Landmark("b", "b", { marker: { x: 3, y: 4 }, image: image("i0") }, { x: 30, y: 40 }),
|
|
209
|
+
])
|
|
210
|
+
|
|
211
|
+
it("applies the calibration to physical lengths, not to pixels", () => {
|
|
212
|
+
const annotations = new Annotations()
|
|
213
|
+
annotations.adjustFactor = 2
|
|
214
|
+
annotations.scale = "cm"
|
|
215
|
+
expect(annotations.scaled(distance())).toBe(10)
|
|
216
|
+
annotations.scale = "px"
|
|
217
|
+
expect(annotations.scaled(distance())).toBe(5)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("doesn't calibrate in pixels", () => {
|
|
221
|
+
const annotations = new Annotations()
|
|
222
|
+
annotations.scale = "px"
|
|
223
|
+
annotations.calibrate(distance(), 10)
|
|
224
|
+
expect(annotations.adjustFactor).toBe(1)
|
|
225
|
+
annotations.scale = "mm"
|
|
226
|
+
annotations.calibrate(distance(), 10)
|
|
227
|
+
expect(annotations.adjustFactor).toBe(0.2)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe("DequeMax2", () => {
|
|
232
|
+
it("keeps the last two items, finds them with equals", () => {
|
|
233
|
+
const deque = new DequeMax2([landmark("a"), landmark("b"), landmark("c")])
|
|
234
|
+
expect(deque.items.map((l) => l.id)).toEqual(["b", "c"])
|
|
235
|
+
deque.add(landmark("c"))
|
|
236
|
+
expect(deque.items.map((l) => l.id)).toEqual(["b", "c"])
|
|
237
|
+
expect([deque.contains("b"), deque.contains(landmark("a")), deque.contains(null)]).toEqual([true, false, false])
|
|
238
|
+
deque.remove("unknown")
|
|
239
|
+
deque.remove("b")
|
|
240
|
+
expect([deque.first?.id, deque.last?.id, deque.isFull()]).toEqual(["c", "c", false])
|
|
241
|
+
})
|
|
242
|
+
})
|
package/tests/project.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ const size = { width: 2000, height: 1000 }
|
|
|
6
6
|
const img = (name: string, extra = {}): ProjectImage => ({ name, label: name, image: "", ...extra })
|
|
7
7
|
|
|
8
8
|
function acquisition(images: string[], standalone: string[] = []): Acquisition {
|
|
9
|
-
return new Acquisition({ id: "a", images: images.map((n) => img(n)), standaloneImages: standalone.map((n) => img(n)), size
|
|
9
|
+
return new Acquisition({ id: "a", images: images.map((n) => img(n)), standaloneImages: standalone.map((n) => img(n)), size })
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
describe("Acquisition", () => {
|
|
@@ -42,7 +42,7 @@ describe("Acquisition", () => {
|
|
|
42
42
|
})
|
|
43
43
|
|
|
44
44
|
it("uses the size of the image when it has one", () => {
|
|
45
|
-
const a = new Acquisition({ id: "a", images: [], standaloneImages: [img("d0", { size: { width: 5, height: 5 } }), img("d1")], size
|
|
45
|
+
const a = new Acquisition({ id: "a", images: [], standaloneImages: [img("d0", { size: { width: 5, height: 5 } }), img("d1")], size })
|
|
46
46
|
expect(a.sizeOf()).toEqual({ width: 5, height: 5 })
|
|
47
47
|
expect(a.sizeOf(a.getImage("d1"))).toEqual(size)
|
|
48
48
|
})
|
|
@@ -50,8 +50,8 @@ describe("Acquisition", () => {
|
|
|
50
50
|
|
|
51
51
|
describe("Project", () => {
|
|
52
52
|
it("selects acquisitions and finds images", () => {
|
|
53
|
-
const vis = new Acquisition({ id: "VIS", images: [img("vis0")], size
|
|
54
|
-
const uv = new Acquisition({ id: "UV", images: [img("uv0")], standaloneImages: [img("uvRGB")], size
|
|
53
|
+
const vis = new Acquisition({ id: "VIS", images: [img("vis0")], size })
|
|
54
|
+
const uv = new Acquisition({ id: "UV", images: [img("uv0")], standaloneImages: [img("uvRGB")], size })
|
|
55
55
|
const p = new Project("A1", [vis, uv])
|
|
56
56
|
expect(p.selectedImage?.name).toBe("vis0")
|
|
57
57
|
p.selectAcquisition("UV")
|
|
@@ -75,21 +75,32 @@ function provider(data: unknown): DataProvider {
|
|
|
75
75
|
|
|
76
76
|
describe("ProjectRepository", () => {
|
|
77
77
|
it("reads the standard payload and resolves the urls", async () => {
|
|
78
|
-
const payload = { acquisitions: [{ id: "stack", images: [{ name: "s0", label: "s0" }], standaloneImages: [{ name: "r", label: "Result" }], size
|
|
78
|
+
const payload = { acquisitions: [{ id: "stack", images: [{ name: "s0", label: "s0", hasThumbnail: true }], standaloneImages: [{ name: "r", label: "Result" }], size }] }
|
|
79
79
|
const project = await new ProjectRepository(provider(payload)).getImages("A1")
|
|
80
80
|
const a = project.acquisition!
|
|
81
81
|
expect(a.images[0]).toMatchObject({ image: "S/A1/s0/full-image", thumbnail: "S/A1/s0/thumbnail" })
|
|
82
82
|
expect(a.standaloneImages[0]).toMatchObject({ label: "Result", image: "S/A1/r/full-image" })
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
it("resolves the thumbnail of the images marked with hasThumbnail only", async () => {
|
|
86
|
+
const payload = { acquisitions: [{
|
|
87
|
+
id: "stack", size,
|
|
88
|
+
images: [{ name: "s0", label: "s0", hasThumbnail: true }, { name: "s1", label: "s1", hasThumbnail: false }, { name: "s2", label: "s2" }],
|
|
89
|
+
standaloneImages: [{ name: "r", label: "Result", hasThumbnail: true }],
|
|
90
|
+
}] }
|
|
91
|
+
const a = (await new ProjectRepository(provider(payload)).getImages("A1")).acquisition!
|
|
92
|
+
expect(a.images.map((image) => image.thumbnail)).toEqual(["S/A1/s0/thumbnail", undefined, undefined])
|
|
93
|
+
expect(a.standaloneImages[0].thumbnail).toBe("S/A1/r/thumbnail")
|
|
94
|
+
})
|
|
95
|
+
|
|
85
96
|
it("adapts a legacy payload with a parser (spectral: one acquisition per group)", async () => {
|
|
86
97
|
type SpectralImage = ProjectImage & { wavelength: { value: number } }
|
|
87
98
|
const legacy = {
|
|
88
|
-
VIS: { spectralImages: [{ name: "v450", label: "v450", wavelength: { value: 450 } }], individualImages: { RGB: { name: "vRGB", label: "RGB" } }, size
|
|
89
|
-
UV: { spectralImages: [], individualImages: { RGB: { name: "uRGB", label: "RGB" } }, size
|
|
99
|
+
VIS: { spectralImages: [{ name: "v450", label: "v450", wavelength: { value: 450 } }], individualImages: { RGB: { name: "vRGB", label: "RGB" } }, size },
|
|
100
|
+
UV: { spectralImages: [], individualImages: { RGB: { name: "uRGB", label: "RGB" } }, size },
|
|
90
101
|
}
|
|
91
102
|
const parse = (data: any): AcquisitionObject<SpectralImage>[] => Object.entries<any>(data).map(([id, group]) => ({
|
|
92
|
-
id, images: group.spectralImages, standaloneImages: Object.values(group.individualImages), size: group.size,
|
|
103
|
+
id, images: group.spectralImages, standaloneImages: Object.values(group.individualImages), size: group.size,
|
|
93
104
|
}))
|
|
94
105
|
const project = await new ProjectRepository<SpectralImage>(provider(legacy), parse).getImages("A1")
|
|
95
106
|
expect(project.acquisitions.map((a) => a.id)).toEqual(["VIS", "UV"])
|
|
@@ -99,9 +110,9 @@ describe("ProjectRepository", () => {
|
|
|
99
110
|
})
|
|
100
111
|
|
|
101
112
|
it("adapts depth: no group, standalone images with their own size", async () => {
|
|
102
|
-
const legacy = { images: [{ id: "d0", label: "D0", size: { width: 10, height: 20 } }]
|
|
113
|
+
const legacy = { images: [{ id: "d0", label: "D0", size: { width: 10, height: 20 } }] }
|
|
103
114
|
const parse = (data: any): AcquisitionObject[] => [{
|
|
104
|
-
id: "depth", images: [], size: data.images[0].size,
|
|
115
|
+
id: "depth", images: [], size: data.images[0].size,
|
|
105
116
|
standaloneImages: data.images.map((i: any) => ({ name: i.id, label: i.label, size: i.size })),
|
|
106
117
|
}]
|
|
107
118
|
const project = await new ProjectRepository(provider(legacy), parse).getImages("A1")
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import type { DataProvider } from "./providers";
|
|
2
|
-
import { WebProvider } from "./web_providers";
|
|
3
|
-
import { providerSettings } from "../../config/appSettings"
|
|
4
|
-
|
|
5
|
-
type ProviderBuilder = (url: string) => DataProvider
|
|
6
|
-
|
|
7
|
-
// "ORTHANC" needs the plugin name of the app, register it with
|
|
8
|
-
// ProviderFactory.register("ORTHANC", (url) => new OrthancProvider(url, "polaroptica"))
|
|
9
|
-
export class ProviderFactory{
|
|
10
|
-
private static _instances = new Map<string, DataProvider>()
|
|
11
|
-
private static _builders = new Map<string, ProviderBuilder>([
|
|
12
|
-
["WEB", (url) => new WebProvider(url)],
|
|
13
|
-
])
|
|
14
|
-
|
|
15
|
-
static register(type : string, builder : ProviderBuilder){
|
|
16
|
-
this._builders.set(type, builder)
|
|
17
|
-
this._instances.delete(type)
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
static get(type : string = providerSettings.type) : DataProvider{
|
|
21
|
-
let instance = this._instances.get(type)
|
|
22
|
-
if(instance == null){
|
|
23
|
-
const builder = this._builders.get(type)
|
|
24
|
-
if(builder == null){
|
|
25
|
-
throw new Error(`Unknown provider type "${type}"`)
|
|
26
|
-
}
|
|
27
|
-
instance = builder(providerSettings.url)
|
|
28
|
-
this._instances.set(type, instance)
|
|
29
|
-
}
|
|
30
|
-
return instance
|
|
31
|
-
}
|
|
32
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { Repository } from "./repository";
|
|
2
|
-
import type { DataProvider } from "../providers/providers";
|
|
3
|
-
import { ProviderFactory } from "../providers/provider_factory";
|
|
4
|
-
import { providerSettings, repositorySettings } from "../../config/appSettings"
|
|
5
|
-
|
|
6
|
-
type RepositoryBuilder = (provider: DataProvider) => Repository<any>
|
|
7
|
-
|
|
8
|
-
// Apps register their repository at startup:
|
|
9
|
-
// RepositoryFactory.register("DATA", (provider) => new StackRepository(provider))
|
|
10
|
-
export class RepositoryFactory{
|
|
11
|
-
private static _instances = new Map<string, Repository<any>>()
|
|
12
|
-
private static _builders = new Map<string, RepositoryBuilder>()
|
|
13
|
-
|
|
14
|
-
static register(type : string, builder : RepositoryBuilder){
|
|
15
|
-
this._builders.set(type, builder)
|
|
16
|
-
this._instances.delete(type)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
static get<R extends Repository<any> = Repository>(type : string = repositorySettings.type) : R{
|
|
20
|
-
let instance = this._instances.get(type)
|
|
21
|
-
if(instance == null){
|
|
22
|
-
const builder = this._builders.get(type)
|
|
23
|
-
if(builder == null){
|
|
24
|
-
throw new Error(`Unknown repository type "${type}"`)
|
|
25
|
-
}
|
|
26
|
-
instance = builder(ProviderFactory.get(providerSettings.type))
|
|
27
|
-
this._instances.set(type, instance)
|
|
28
|
-
}
|
|
29
|
-
return instance as R
|
|
30
|
-
}
|
|
31
|
-
}
|