Skip to content

Commit 1baaecf

Browse files
authored
feat: LEAP-2012: Add official docs remaining plugins to the repo (#10)
1 parent 9a42248 commit 1baaecf

File tree

10 files changed

+277
-26
lines changed

10 files changed

+277
-26
lines changed

docs/banner.png

-115 KB
Loading

manifest.json

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[
22
{
3-
"title": "Bulk labeling for text spans",
4-
"description": "Assigns labels to all occurrences of the selected text at once",
3+
"title": "Bulk labeling for text spans with keyboard shortcuts",
4+
"description": "Assigns labels to all occurrences of the selected text at once and removes them",
55
"path": "bulk-labeling",
66
"private": false
77
},
@@ -64,5 +64,17 @@
6464
"description": "Checks that the introduced text is a valid JSON",
6565
"path": "validate-json-in-textarea",
6666
"private": false
67+
},
68+
{
69+
"title": "Simple content moderation",
70+
"description": "Prevents saving annotations containing inappropriate content",
71+
"path": "simple-content-moderation",
72+
"private": false
73+
},
74+
{
75+
"title": "Multi-frame video view",
76+
"description": "Synchronizes multiple video views to display a video with different frame offsets",
77+
"path": "multi-frame-video-view",
78+
"private": false
6779
}
6880
]

src/bulk-labeling/plugin.js

+85-22
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,101 @@
11
/**
2-
* Automatically creates all the text regions containing all instances of the selected text.
2+
* Automatically creates text regions for all instances of the selected text and deletes existing regions
3+
* when the shift key is pressed.
34
*/
45

5-
// It will be triggered when a text selection happens
6-
LSI.on("entityCreate", (region) => {
6+
// Track the state of the shift key
7+
let isShiftKeyPressed = false;
8+
9+
window.addEventListener("keydown", (e) => {
10+
if (e.key === "Shift") {
11+
isShiftKeyPressed = true;
12+
}
13+
});
14+
15+
window.addEventListener("keyup", (e) => {
16+
if (e.key === "Shift") {
17+
isShiftKeyPressed = false;
18+
}
19+
});
20+
21+
LSI.on("entityDelete", (region) => {
22+
if (!isShiftKeyPressed) return; // Only proceed if the shift key is pressed
23+
724
if (window.BULK_REGIONS) return;
25+
window.BULK_REGIONS = true;
26+
setTimeout(() => {
27+
window.BULK_REGIONS = false;
28+
}, 1000);
29+
30+
const existingEntities = Htx.annotationStore.selected.regions;
31+
const regionsToDelete = existingEntities.filter((entity) => {
32+
const deletedText = region.text.toLowerCase().replace("\\\\n", " ");
33+
const otherText = entity.text.toLowerCase().replace("\\\\n", " ");
34+
return deletedText === otherText && region.labels[0] === entity.labels[0];
35+
});
836

37+
for (const region of regionsToDelete) {
38+
Htx.annotationStore.selected.deleteRegion(region);
39+
}
40+
41+
Htx.annotationStore.selected.updateObjects();
42+
});
43+
44+
LSI.on("entityCreate", (region) => {
45+
if (!isShiftKeyPressed) return;
46+
47+
if (window.BULK_REGIONS) return;
948
window.BULK_REGIONS = true;
1049
setTimeout(() => {
1150
window.BULK_REGIONS = false;
1251
}, 1000);
1352

53+
const existingEntities = Htx.annotationStore.selected.regions;
54+
1455
setTimeout(() => {
15-
// Find all the text regions matching the selection
16-
const matches = Array.from(
17-
region.object._value.matchAll(new RegExp(region.text, "gi")),
56+
// Prevent tagging a single character
57+
if (region.text.length < 2) return;
58+
regexp = new RegExp(
59+
region.text.replace("\\\\n", "\\\\s+").replace(" ", "\\\\s+"),
60+
"gi",
1861
);
19-
for (const m of matches) {
20-
if (m.index === region.startOffset) continue;
21-
22-
// Include them in the results as new selections
23-
Htx.annotationStore.selected.createResult(
24-
{
25-
text: region.text,
26-
start: "/span[1]/text()[1]",
27-
startOffset: m.index,
28-
end: "/span[1]/text()[1]",
29-
endOffset: m.index + region.text.length,
30-
},
31-
{ labels: [...region.labeling.value.labels] },
32-
region.labeling.from_name,
33-
region.object,
34-
);
62+
const matches = Array.from(region.object._value.matchAll(regexp));
63+
for (const match of matches) {
64+
if (match.index === region.startOffset) continue;
65+
66+
const startOffset = match.index;
67+
const endOffset = match.index + region.text.length;
68+
69+
// Check for existing entities with overlapping start and end offset
70+
let isDuplicate = false;
71+
for (const entity of existingEntities) {
72+
if (
73+
startOffset <= entity.globalOffsets.end &&
74+
entity.globalOffsets.start <= endOffset
75+
) {
76+
isDuplicate = true;
77+
break;
78+
}
79+
}
80+
81+
if (!isDuplicate) {
82+
Htx.annotationStore.selected.createResult(
83+
{
84+
text: region.text,
85+
start: "/span[1]/text()[1]",
86+
startOffset: startOffset,
87+
end: "/span[1]/text()[1]",
88+
endOffset: endOffset,
89+
},
90+
{
91+
labels: [...region.labeling.value.labels],
92+
},
93+
region.labeling.from_name,
94+
region.object,
95+
);
96+
}
3597
}
98+
3699
Htx.annotationStore.selected.updateObjects();
37100
}, 100);
38101
});

src/multi-frame-video-view/data.json

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"data": {
3+
"video_url": "https://example.com/path/to/video.mp4"
4+
}
5+
}

src/multi-frame-video-view/plugin.js

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Multi-frame video view plugin
3+
*
4+
* This plugin synchronizes three video views to display a video with three frames:
5+
* -1 frame, 0 frame, and +1 frame.
6+
*
7+
* It also synchronizes the timeline labels to the 0 frame.
8+
*/
9+
10+
async function initMultiFrameVideoView() {
11+
// Wait for the Label Studio Interface to be ready
12+
await LSI;
13+
14+
// Get references to the video objects by their names
15+
const videoMinus1 = LSI.annotation.names.get("videoMinus1");
16+
const video0 = LSI.annotation.names.get("video0");
17+
const videoPlus1 = LSI.annotation.names.get("videoPlus1");
18+
19+
if (!videoMinus1 || !video0 || !videoPlus1) return;
20+
21+
// Convert frameRate to a number and ensure it's valid
22+
const frameRate = Number.parseFloat(video0.framerate) || 24;
23+
const frameDuration = 1 / frameRate;
24+
25+
// Function to adjust video sync with offset and guard against endless loops
26+
function adjustVideoSync(video, offsetFrames) {
27+
video.isSyncing = false;
28+
29+
for (const event of ["seek", "play", "pause"]) {
30+
video.syncHandlers.set(event, (data) => {
31+
if (!video.isSyncing) {
32+
video.isSyncing = true;
33+
34+
if (video.ref.current && video !== video0) {
35+
const videoElem = video.ref.current;
36+
37+
adjustedTime =
38+
(video0.ref.current.currentFrame + offsetFrames) * frameDuration;
39+
adjustedTime = Math.max(
40+
0,
41+
Math.min(adjustedTime, video.ref.current.duration),
42+
);
43+
44+
if (data.playing) {
45+
if (!videoElem.playing) videoElem.play();
46+
} else {
47+
if (videoElem.playing) videoElem.pause();
48+
}
49+
50+
if (data.speed) {
51+
video.speed = data.speed;
52+
}
53+
54+
videoElem.currentTime = adjustedTime;
55+
if (
56+
Math.abs(videoElem.currentTime - adjustedTime) >
57+
frameDuration / 2
58+
) {
59+
videoElem.currentTime = adjustedTime;
60+
}
61+
}
62+
63+
video.isSyncing = false;
64+
}
65+
});
66+
}
67+
}
68+
69+
// Adjust offsets for each video
70+
adjustVideoSync(videoMinus1, -1);
71+
adjustVideoSync(videoPlus1, 1);
72+
adjustVideoSync(video0, 0);
73+
}
74+
75+
// Initialize the plugin
76+
initMultiFrameVideoView();

src/multi-frame-video-view/view.xml

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<View>
2+
<View style="display: flex">
3+
<View style="width: 100%">
4+
<Header value="Video -1 Frame"/>
5+
<Video name="videoMinus1" value="$video_url"
6+
height="200" sync="lag" frameRate="29.97"/>
7+
</View>
8+
<View style="width: 100%">
9+
<Header value="Video +1 Frame"/>
10+
<Video name="videoPlus1" value="$video_url"
11+
height="200" sync="lag" frameRate="29.97"/>
12+
</View>
13+
</View>
14+
<View style="width: 100%; margin-bottom: 1em;">
15+
<Header value="Video 0 Frame"/>
16+
<Video name="video0" value="$video_url"
17+
height="400" sync="lag" frameRate="29.97"/>
18+
</View>
19+
<TimelineLabels name="timelinelabels" toName="video0">
20+
<Label value="class1"/>
21+
<Label value="class2"/>
22+
</TimelineLabels>
23+
</View>
24+
25+
<!--
26+
{
27+
"data": {
28+
"video_url": "https://example.com/path/to/video.mp4"
29+
}
30+
}
31+
-->

src/pausing-annotator/plugin.js

+8-2
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,15 @@ LSI.on("submitAnnotation", async (_store, annotation) => {
131131

132132
for (const rule of RULES.global) {
133133
const result = rule(stats);
134+
134135
if (result) {
135136
localStorage.setItem(key, "[]");
136-
await pause(result);
137+
138+
try {
139+
await pause(result);
140+
} catch (error) {
141+
Htx.showModal(error.message, "error");
142+
}
137143
return;
138144
}
139145
}
@@ -164,7 +170,7 @@ LSI.on("submitAnnotation", async (_store, annotation) => {
164170
*/
165171
async function pause(verbose_reason) {
166172
const body = {
167-
reason: "PLUGIN",
173+
reason: "CUSTOM_SCRIPT",
168174
verbose_reason,
169175
};
170176
const options = {
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[
2+
{
3+
"audio": "https://data.heartex.net/librispeech/dev-clean/3536/8226/3536-8226-0024.flac.wav"
4+
}
5+
]
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Simple content moderation plugin that prevents saving annotations containing hate speech
3+
*
4+
* This plugin monitors text entered into TextArea regions and checks for the word "hate"
5+
* before allowing the annotation to be saved. If found, it shows an error message and
6+
* prevents submission. This would happen only once, if user clicks Submit again it would
7+
* work with no errors.
8+
*
9+
* The plugin uses Label Studio's beforeSaveAnnotation event which is triggered before
10+
* an annotation is saved. Returning false from this event handler prevents the save
11+
* operation from completing.
12+
*/
13+
14+
let dismissed = false;
15+
16+
LSI.on("beforeSaveAnnotation", (store, ann) => {
17+
// text in TextArea is always an array
18+
const obscene = ann.results.find(
19+
(r) =>
20+
r.type === "textarea" && r.value.text.some((t) => t.includes("hate")),
21+
);
22+
if (!obscene || dismissed) return true;
23+
24+
// select region to see textarea
25+
if (!obscene.area.classification) ann.selectArea(obscene.area);
26+
27+
Htx.showModal("The word 'hate' is disallowed", "error");
28+
dismissed = true;
29+
30+
return false;
31+
});
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<View>
2+
<Labels name="labels" toName="audio">
3+
<Label value="Speech" />
4+
<Label value="Noise" />
5+
</Labels>
6+
7+
<Audio name="audio" value="$audio"/>
8+
9+
<TextArea name="transcription" toName="audio"
10+
editable="true"
11+
perRegion="true"
12+
required="true"
13+
/>
14+
</View>
15+
16+
<!--
17+
[
18+
{
19+
"audio": "https://data.heartex.net/librispeech/dev-clean/3536/8226/3536-8226-0024.flac.wav"
20+
}
21+
]
22+
-->

0 commit comments

Comments
 (0)