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
Binary file modified docs/banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 14 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[
{
"title": "Bulk labeling for text spans",
"description": "Assigns labels to all occurrences of the selected text at once",
"title": "Bulk labeling for text spans with keyboard shortcuts",
"description": "Assigns labels to all occurrences of the selected text at once and removes them",
"path": "bulk-labeling",
"private": false
},
Expand Down Expand Up @@ -64,5 +64,17 @@
"description": "Checks that the introduced text is a valid JSON",
"path": "validate-json-in-textarea",
"private": false
},
{
"title": "Simple content moderation",
"description": "Prevents saving annotations containing inappropriate content",
"path": "simple-content-moderation",
"private": false
},
{
"title": "Multi-frame video view",
"description": "Synchronizes multiple video views to display a video with different frame offsets",
"path": "multi-frame-video-view",
"private": false
}
]
107 changes: 85 additions & 22 deletions src/bulk-labeling/plugin.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,101 @@
/**
* Automatically creates all the text regions containing all instances of the selected text.
* Automatically creates text regions for all instances of the selected text and deletes existing regions
* when the shift key is pressed.
*/

// It will be triggered when a text selection happens
LSI.on("entityCreate", (region) => {
// Track the state of the shift key
let isShiftKeyPressed = false;

window.addEventListener("keydown", (e) => {
if (e.key === "Shift") {
isShiftKeyPressed = true;
}
});

window.addEventListener("keyup", (e) => {
if (e.key === "Shift") {
isShiftKeyPressed = false;
}
});

LSI.on("entityDelete", (region) => {
if (!isShiftKeyPressed) return; // Only proceed if the shift key is pressed

if (window.BULK_REGIONS) return;
window.BULK_REGIONS = true;
setTimeout(() => {
window.BULK_REGIONS = false;
}, 1000);

const existingEntities = Htx.annotationStore.selected.regions;
const regionsToDelete = existingEntities.filter((entity) => {
const deletedText = region.text.toLowerCase().replace("\\\\n", " ");
const otherText = entity.text.toLowerCase().replace("\\\\n", " ");
return deletedText === otherText && region.labels[0] === entity.labels[0];
});

for (const region of regionsToDelete) {
Htx.annotationStore.selected.deleteRegion(region);
}

Htx.annotationStore.selected.updateObjects();
});

LSI.on("entityCreate", (region) => {
if (!isShiftKeyPressed) return;

if (window.BULK_REGIONS) return;
window.BULK_REGIONS = true;
setTimeout(() => {
window.BULK_REGIONS = false;
}, 1000);

const existingEntities = Htx.annotationStore.selected.regions;

setTimeout(() => {
// Find all the text regions matching the selection
const matches = Array.from(
region.object._value.matchAll(new RegExp(region.text, "gi")),
// Prevent tagging a single character
if (region.text.length < 2) return;
regexp = new RegExp(
region.text.replace("\\\\n", "\\\\s+").replace(" ", "\\\\s+"),
"gi",
);
for (const m of matches) {
if (m.index === region.startOffset) continue;

// Include them in the results as new selections
Htx.annotationStore.selected.createResult(
{
text: region.text,
start: "/span[1]/text()[1]",
startOffset: m.index,
end: "/span[1]/text()[1]",
endOffset: m.index + region.text.length,
},
{ labels: [...region.labeling.value.labels] },
region.labeling.from_name,
region.object,
);
const matches = Array.from(region.object._value.matchAll(regexp));
for (const match of matches) {
if (match.index === region.startOffset) continue;

const startOffset = match.index;
const endOffset = match.index + region.text.length;

// Check for existing entities with overlapping start and end offset
let isDuplicate = false;
for (const entity of existingEntities) {
if (
startOffset <= entity.globalOffsets.end &&
entity.globalOffsets.start <= endOffset
) {
isDuplicate = true;
break;
}
}

if (!isDuplicate) {
Htx.annotationStore.selected.createResult(
{
text: region.text,
start: "/span[1]/text()[1]",
startOffset: startOffset,
end: "/span[1]/text()[1]",
endOffset: endOffset,
},
{
labels: [...region.labeling.value.labels],
},
region.labeling.from_name,
region.object,
);
}
}

Htx.annotationStore.selected.updateObjects();
}, 100);
});
5 changes: 5 additions & 0 deletions src/multi-frame-video-view/data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"data": {
"video_url": "https://example.com/path/to/video.mp4"
}
}
76 changes: 76 additions & 0 deletions src/multi-frame-video-view/plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Multi-frame video view plugin
*
* This plugin synchronizes three video views to display a video with three frames:
* -1 frame, 0 frame, and +1 frame.
*
* It also synchronizes the timeline labels to the 0 frame.
*/

async function initMultiFrameVideoView() {
// Wait for the Label Studio Interface to be ready
await LSI;

// Get references to the video objects by their names
const videoMinus1 = LSI.annotation.names.get("videoMinus1");
const video0 = LSI.annotation.names.get("video0");
const videoPlus1 = LSI.annotation.names.get("videoPlus1");

if (!videoMinus1 || !video0 || !videoPlus1) return;

// Convert frameRate to a number and ensure it's valid
const frameRate = Number.parseFloat(video0.framerate) || 24;
const frameDuration = 1 / frameRate;

// Function to adjust video sync with offset and guard against endless loops
function adjustVideoSync(video, offsetFrames) {
video.isSyncing = false;

for (const event of ["seek", "play", "pause"]) {
video.syncHandlers.set(event, (data) => {
if (!video.isSyncing) {
video.isSyncing = true;

if (video.ref.current && video !== video0) {
const videoElem = video.ref.current;

adjustedTime =
(video0.ref.current.currentFrame + offsetFrames) * frameDuration;
adjustedTime = Math.max(
0,
Math.min(adjustedTime, video.ref.current.duration),
);

if (data.playing) {
if (!videoElem.playing) videoElem.play();
} else {
if (videoElem.playing) videoElem.pause();
}

if (data.speed) {
video.speed = data.speed;
}

videoElem.currentTime = adjustedTime;
if (
Math.abs(videoElem.currentTime - adjustedTime) >
frameDuration / 2
) {
videoElem.currentTime = adjustedTime;
}
}

video.isSyncing = false;
}
});
}
}

// Adjust offsets for each video
adjustVideoSync(videoMinus1, -1);
adjustVideoSync(videoPlus1, 1);
adjustVideoSync(video0, 0);
}

// Initialize the plugin
initMultiFrameVideoView();
31 changes: 31 additions & 0 deletions src/multi-frame-video-view/view.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<View>
<View style="display: flex">
<View style="width: 100%">
<Header value="Video -1 Frame"/>
<Video name="videoMinus1" value="$video_url"
height="200" sync="lag" frameRate="29.97"/>
</View>
<View style="width: 100%">
<Header value="Video +1 Frame"/>
<Video name="videoPlus1" value="$video_url"
height="200" sync="lag" frameRate="29.97"/>
</View>
</View>
<View style="width: 100%; margin-bottom: 1em;">
<Header value="Video 0 Frame"/>
<Video name="video0" value="$video_url"
height="400" sync="lag" frameRate="29.97"/>
</View>
<TimelineLabels name="timelinelabels" toName="video0">
<Label value="class1"/>
<Label value="class2"/>
</TimelineLabels>
</View>

<!--
{
"data": {
"video_url": "https://example.com/path/to/video.mp4"
}
}
-->
10 changes: 8 additions & 2 deletions src/pausing-annotator/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,15 @@ LSI.on("submitAnnotation", async (_store, annotation) => {

for (const rule of RULES.global) {
const result = rule(stats);

if (result) {
localStorage.setItem(key, "[]");
await pause(result);

try {
await pause(result);
} catch (error) {
Htx.showModal(error.message, "error");
}
return;
}
}
Expand Down Expand Up @@ -164,7 +170,7 @@ LSI.on("submitAnnotation", async (_store, annotation) => {
*/
async function pause(verbose_reason) {
const body = {
reason: "PLUGIN",
reason: "CUSTOM_SCRIPT",
verbose_reason,
};
const options = {
Expand Down
5 changes: 5 additions & 0 deletions src/simple-content-moderation/data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
{
"audio": "https://data.heartex.net/librispeech/dev-clean/3536/8226/3536-8226-0024.flac.wav"
}
]
31 changes: 31 additions & 0 deletions src/simple-content-moderation/plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Simple content moderation plugin that prevents saving annotations containing hate speech
*
* This plugin monitors text entered into TextArea regions and checks for the word "hate"
* before allowing the annotation to be saved. If found, it shows an error message and
* prevents submission. This would happen only once, if user clicks Submit again it would
* work with no errors.
*
* The plugin uses Label Studio's beforeSaveAnnotation event which is triggered before
* an annotation is saved. Returning false from this event handler prevents the save
* operation from completing.
*/

let dismissed = false;

LSI.on("beforeSaveAnnotation", (store, ann) => {
// text in TextArea is always an array
const obscene = ann.results.find(
(r) =>
r.type === "textarea" && r.value.text.some((t) => t.includes("hate")),
);
if (!obscene || dismissed) return true;

// select region to see textarea
if (!obscene.area.classification) ann.selectArea(obscene.area);

Htx.showModal("The word 'hate' is disallowed", "error");
dismissed = true;

return false;
});
22 changes: 22 additions & 0 deletions src/simple-content-moderation/view.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<View>
<Labels name="labels" toName="audio">
<Label value="Speech" />
<Label value="Noise" />
</Labels>

<Audio name="audio" value="$audio"/>

<TextArea name="transcription" toName="audio"
editable="true"
perRegion="true"
required="true"
/>
</View>

<!--
[
{
"audio": "https://data.heartex.net/librispeech/dev-clean/3536/8226/3536-8226-0024.flac.wav"
}
]
-->