Skip to content
Open
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
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ You can find out more about our tool from the newly released [documentation][14]
[makesense.ai][1] strives to significantly reduce the time you have to spend on photo labeling. We are doing our best to integrate the latest and greatest AI models, that can give you recommendations as well as automate repetitive and tedious activities.

* [YOLOv5][16] is our most powerful integration yet. Thanks to the use of [yolov5js][17] you can load not only pretrained models from [yolov5js-zoo](18), but above all your own models trained thanks to YOLOv5 and [exported](19) to tfjs format.
* [SSD][8] pretrained on the [COCO dataset][9], which will do some of the work for you in drawing bounding boxes on photos and also (in some cases) suggest a label.
* [SSD][8] pretrained on the [COCO dataset][9], which will do some of the work for you in drawing bounding boxes on photos and also (in some cases) suggest a label.
* [PoseNet][11] is a vision model that can be used to estimate the pose of a person in an image or video by estimating where key body joints are.

The engine that drives our AI functionalities is [TensorFlow.js][10] - JS version of the most popular framework for training neural networks. This choice allows us not only to speed up your work but also to care about the privacy of your data, because unlike with other commercial and open-source tools, your photos do not have to be transferred to the server. This time AI comes to your device!
Expand Down Expand Up @@ -59,7 +59,11 @@ docker run -dit -p 3000:3000 --restart=always --name=make-sense make-sense
# Get Docker Container Logs
docker logs make-sense

# Stop
docker rm make-sense -f

# Access make-sense: http://localhost:3000/

```

## ⌨️ Keyboard Shortcuts
Expand All @@ -86,13 +90,13 @@ docker logs make-sense
| **Point** | ✓ | ✗ | ☐ | ☐ | ☐ | ✗ |
| **Line** | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| **Rect** | ✓ | ✓ | ✓ | ☐ | ☐ | ✗ |
| **Polygon** | ☐ | | ☐ | ✓ | ✓ | ☐ |
| **Polygon** | ☐ | | ☐ | ✓ | ✓ | ☐ |
| **Label** | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |

**Table 2.** The matrix of supported labels export formats, where:
* ✓ - supported format
* ☐ - not yet supported format
* ✗ - format does not make sense for a given label type
* ✗ - format does not make sense for a given label type

You can find examples of export files along with a description and schema on our [Wiki][7].

Expand All @@ -103,13 +107,13 @@ You can find examples of export files along with a description and schema on our
| **Point** | ☐ | ✗ | ☐ | ☐ | ☐ | ✗ |
| **Line** | ☐ | ✗ | ✗ | ✗ | ✗ | ✗ |
| **Rect** | ☐ | ✓ | ✓ | ☐ | ✓ | ✗ |
| **Polygon** | ☐ | | ☐ | ☐ | ✓ | ☐ |
| **Polygon** | ☐ | | ☐ | ☐ | ✓ | ☐ |
| **Label** | ☐ | ✗ | ✗ | ✗ | ✗ | ✗ |

**Table 3.** The matrix of supported labels import formats
* ✓ - supported format
* ☐ - not yet supported format
* ✗ - format does not make sense for a given label type
* ✗ - format does not make sense for a given label type

## 🔐 Privacy

Expand All @@ -122,7 +126,7 @@ If you are just starting your adventure with deep learning and would like to lea

## 🏆 Contribution

<p align="center">
<p align="center">
<a href="https://github.com/SkalskiP/make-sense/graphs/contributors">
<img src="https://contrib.rocks/image?repo=SkalskiP/make-sense" />
</a>
Expand Down Expand Up @@ -161,6 +165,6 @@ This project is licensed under the GPL-3.0 License - see the [LICENSE][2] file f
[14]: https://skalskip.github.io/make-sense/
[15]: https://github.com/SkalskiP/make-sense/issues
[16]: https://github.com/ultralytics/yolov5
[17]: https://github.com/SkalskiP/yolov5js
[17]: https://github.com/SkalskiP/yolov5js
[18]: https://github.com/SkalskiP/yolov5js-zoo
[19]: https://github.com/ultralytics/yolov5/blob/master/export.py
5 changes: 2 additions & 3 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
FROM node:16.16.0

RUN apt-get update && apt-get -y install git && rm -rf /var/lib/apt/lists/*
FROM node:20-alpine
RUN apk add --no-cache git

COPY ./ /make-sense

Expand Down
4 changes: 4 additions & 0 deletions src/data/ExportFormatData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export const ExportFormatData: ExportFormatDataMap = {
{
type: AnnotationFormatType.COCO,
label: 'Single file in COCO JSON format.'
},
{
type: AnnotationFormatType.YOLO_POLYGON,
label: 'A .zip package containing files in YOLO polygon format.'
}
],
[LabelType.IMAGE_RECOGNITION]: [
Expand Down
4 changes: 4 additions & 0 deletions src/data/ImportFormatData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export const ImportFormatData: ImportFormatDataMap = {
{
type: AnnotationFormatType.COCO,
label: 'Single file in COCO JSON format.'
},
{
type: AnnotationFormatType.YOLO_POLYGON,
label: 'Multiple files in YOLO polygon format along with labels names definition - labels.txt file.'
}
],
[LabelType.IMAGE_RECOGNITION]: []
Expand Down
4 changes: 3 additions & 1 deletion src/data/ImporterSpecData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {AnnotationImporter} from '../logic/import/AnnotationImporter';
import {COCOImporter} from '../logic/import/coco/COCOImporter';
import {YOLOImporter} from '../logic/import/yolo/YOLOImporter';
import {VOCImporter} from '../logic/import/voc/VOCImporter';
import {YOLOPolygonImporter} from '../logic/import/yolo/YOLOPolygonImporter';

export type ImporterSpecDataMap = Record<AnnotationFormatType, typeof AnnotationImporter>;

Expand All @@ -13,5 +14,6 @@ export const ImporterSpecData: ImporterSpecDataMap = {
[AnnotationFormatType.JSON]: undefined,
[AnnotationFormatType.VGG]: undefined,
[AnnotationFormatType.VOC]: VOCImporter,
[AnnotationFormatType.YOLO]: YOLOImporter
[AnnotationFormatType.YOLO]: YOLOImporter,
[AnnotationFormatType.YOLO_POLYGON]: YOLOPolygonImporter,
}
5 changes: 3 additions & 2 deletions src/data/enums/AnnotationFormatType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export enum AnnotationFormatType {
CSV = 'CSV',
JSON = 'JSON',
VOC = 'VOC',
VGG = 'VGG'
}
VGG = 'VGG',
YOLO_POLYGON = 'YOLO_POLYGON',
}
6 changes: 5 additions & 1 deletion src/logic/export/polygon/PolygonLabelsExporter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {AnnotationFormatType} from "../../../data/enums/AnnotationFormatType";
import {VGGExporter} from "./VGGExporter";
import {COCOExporter} from "./COCOExporter";
import {YOLOPolygonExporter} from "./YOLOPolygonExporter";

export class PolygonLabelsExporter {
public static export(exportFormatType: AnnotationFormatType): void {
Expand All @@ -11,8 +12,11 @@ export class PolygonLabelsExporter {
case AnnotationFormatType.COCO:
COCOExporter.export();
break;
case AnnotationFormatType.YOLO_POLYGON:
YOLOPolygonExporter.export();
break;
default:
return;
}
}
}
}
89 changes: 89 additions & 0 deletions src/logic/export/polygon/YOLOPolygonExporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {ImageData, LabelName, LabelPolygon} from '../../../store/labels/types';
import {ImageRepository} from '../../imageRepository/ImageRepository';
import JSZip from 'jszip';
import {saveAs} from 'file-saver';
import {LabelsSelector} from '../../../store/selectors/LabelsSelector';
import {ExporterUtil} from '../../../utils/ExporterUtil';
import {findIndex} from 'lodash';
import {ISize} from '../../../interfaces/ISize';
import {NumberUtil} from '../../../utils/NumberUtil';
import {IPoint} from '../../../interfaces/IPoint';

export class YOLOPolygonExporter {
public static export(): void {
const zip = new JSZip();
LabelsSelector.getImagesData()
.forEach((imageData: ImageData) => {
const fileContent: string = YOLOPolygonExporter.wrapPolygonLabelsIntoYOLO(imageData);
if (fileContent) {
const fileName: string = imageData.fileData.name.replace(/\.[^/.]+$/, '.txt');
try {
zip.file(fileName, fileContent);
} catch (error) {
throw new Error(error as string);
}
}
});

try {
zip.generateAsync({type: 'blob'})
.then((content: Blob) => {
saveAs(content, `${ExporterUtil.getExportFileName()}.zip`);
});
} catch (error) {
throw new Error(error as string);
}
}

public static wrapPolygonLabelIntoYOLO(
labelPolygon: LabelPolygon,
labelNames: LabelName[],
imageSize: ISize
): string {
const snapAndFix = (value: number) => NumberUtil.snapValueToRange(value, 0, 1).toFixed(6);
const classIdx: string = findIndex(labelNames, {id: labelPolygon.labelId}).toString();

// Convert vertices to normalized coordinates
const normalizedCoords: string[] = [];

// Process vertices, excluding the last one if it's a duplicate of the first (closing point)
const vertices = labelPolygon.vertices;
const vertexCount = vertices.length;
const lastIndex = vertexCount - 1;

// Check if last vertex is duplicate of first (polygon closed)
const isDuplicate = vertexCount > 1 &&
vertices[0].x === vertices[lastIndex].x &&
vertices[0].y === vertices[lastIndex].y;

const endIndex = isDuplicate ? lastIndex : vertexCount;

for (let i = 0; i < endIndex; i++) {
const vertex = vertices[i];
const normalizedX = vertex.x / imageSize.width;
const normalizedY = vertex.y / imageSize.height;

normalizedCoords.push(snapAndFix(normalizedX));
normalizedCoords.push(snapAndFix(normalizedY));
}

return [classIdx, ...normalizedCoords].join(' ');
}

private static wrapPolygonLabelsIntoYOLO(imageData: ImageData): string {
if (imageData.labelPolygons.length === 0 || !imageData.loadStatus)
return null;

const labelNames: LabelName[] = LabelsSelector.getLabelNames();
const image: HTMLImageElement = ImageRepository.getById(imageData.id);
const imageSize: ISize = {width: image.width, height: image.height};

const labelPolygonsString: string[] = imageData.labelPolygons
.filter((labelPolygon: LabelPolygon) => labelPolygon.labelId !== null)
.map((labelPolygon: LabelPolygon) => {
return YOLOPolygonExporter.wrapPolygonLabelIntoYOLO(labelPolygon, labelNames, imageSize);
});

return labelPolygonsString.join('\n');
}
}
148 changes: 148 additions & 0 deletions src/logic/import/yolo/YOLOPolygonImporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// YOLOPolygonImporter.ts
import { v4 as uuidv4 } from 'uuid';
import {LabelsSelector} from '../../../store/selectors/LabelsSelector';
import {ImageData, LabelName} from '../../../store/labels/types';
import {ArrayUtil, PartitionResult} from '../../../utils/ArrayUtil';
import {ImageDataUtil} from '../../../utils/ImageDataUtil';
import {LabelUtil} from '../../../utils/LabelUtil';
import {LabelType} from '../../../data/enums/LabelType';
import {AnnotationImporter, ImportResult} from '../AnnotationImporter';
import {
NoLabelNamesFileProvidedError,
YOLOLabelsReadingError,
YOLOAnnotationsLoadingError
} from './YOLOErrors';
import {YOLOUtils} from './YOLOUtils';
import {ISize} from '../../../interfaces/ISize';
import {Settings} from '../../../settings/Settings';
import {ImageRepository} from '../../imageRepository/ImageRepository';

type LabelNameMap = { [classIdx: number]: LabelName; };
type FileByName = { [name: string]: File; };

export class YOLOPolygonImporter extends AnnotationImporter {
public import(
filesData: File[],
onSuccess: (imagesData: ImageData[], labelNames: LabelName[]) => any,
onFailure: (error?: Error) => any
): void {
(async () => {
try {
// 1) Separate labels.txt and annotation .txt files
const labelsFile = filesData.find(f => f.name.toLowerCase() === 'labels.txt');
if (!labelsFile) throw new NoLabelNamesFileProvidedError();

const annotationFiles = filesData.filter(f =>
f !== labelsFile && f.name.toLowerCase().endsWith('.txt')
);
// Allow case when user imported only labels first; still useful to resolve label palette.
// But we’ll continue if there are annotation files.

// 2) Read label names
const labelNames = await this.readLabels(labelsFile);

// Map class index -> LabelName with deterministic colors
const labelNameMap: LabelNameMap = this.toLabelNameMap(labelNames);

// 3) Prepare image data maps
const inputImagesData: ImageData[] = LabelsSelector.getImagesData();
const cleanImageData: ImageData[] = inputImagesData.map((item: ImageData) =>
ImageDataUtil.cleanAnnotations(item)
);

// Build a quick lookup: annotationFileName -> File
const annByName: FileByName = annotationFiles.reduce((acc: FileByName, f: File) => {
acc[f.name] = f;
return acc;
}, {});

// 4) Load images into ImageRepository before accessing them
await ImageDataUtil.loadMissingImages(cleanImageData);

// 5) Apply polygons to images where we find a matching .txt
// A matching file is "<image-stem>.txt"
const updatedImages: ImageData[] = await this.applyYOLOPolygons(cleanImageData, annByName, labelNames);

// 6) Done
onSuccess(
ImageDataUtil.arrange(updatedImages, inputImagesData.map(i => i.id)),
Object.values(labelNameMap)
);
} catch (err) {
onFailure(err as Error);
}
})();
}

private async readLabels(labelsFile: File): Promise<LabelName[]> {
try {
const content = await this.readFileText(labelsFile);
return YOLOUtils.parseLabelsNamesFromString(content);
} catch {
throw new YOLOLabelsReadingError();
}
}

private toLabelNameMap(labelNames: LabelName[]): LabelNameMap {
// Preserve incoming ids/colors; if you want fresh palette per class index, regenerate here.
// Also ensure they have stable colors across sessions.
const map: LabelNameMap = {};
labelNames.forEach((ln, idx) => {
map[idx] = {
id: ln.id ?? uuidv4(),
name: ln.name,
color: ln.color ?? ArrayUtil.getByInfiniteIndex(Settings.LABEL_COLORS_PALETTE, idx)
};
});
return map;
}

private async applyYOLOPolygons(
images: ImageData[],
annByName: FileByName,
labelNames: LabelName[]
): Promise<ImageData[]> {
const promises = images.map(async (img) => {
const stem = this.stemOf(img.fileData.name);
const annFile = annByName[`${stem}.txt`];
if (!annFile) return img; // no annotations for this image

const text = await this.readFileText(annFile);

// Get the actual loaded image from the repository
const image: HTMLImageElement = ImageRepository.getById(img.id);
const imageSize: ISize = {
width: image.width,
height: image.height
};

// Parse as polygons; each line is one instance polygon
const polygons = YOLOUtils.parseYOLOPolygonAnnotationsFromString(
text, labelNames, imageSize, img.fileData.name
);

// Push to image
polygons.forEach(poly => {
img.labelPolygons.push(poly);
});

return img;
});

return Promise.all(promises);
}

private stemOf(filename: string): string {
const idx = filename.lastIndexOf('.');
return idx === -1 ? filename : filename.slice(0, idx);
}

private readFileText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new YOLOAnnotationsLoadingError(`Unable to read ${file.name}`));
reader.onloadend = (evt: any) => resolve(evt.target.result as string);
reader.readAsText(file);
});
}
}
Loading