Comparar commits

...

32 Commits

Autor SHA1 Mensagem Data
Tanner Barlow a205a78858 wip 2019-08-19 15:08:44 -07:00
Wallace Breza 745e854cc4 Release 2.1.0 (#790)
Updates package version and changelog for 2.1.0 release
2019-04-29 14:39:24 -07:00
Wallace Breza 2234c8a0cc fix: Updates backwards compat & fixes cntk export image bug (#789)
Fixes an issue where the images exported out of a video file were missing file extension for video projects.
2019-04-29 14:18:45 -07:00
Wallace Breza 4d02db4215 fix: Updates export options for pascalVOC rename (#788)
Adds a check during project load to update the export options if project was using previous pascalVOC name.
2019-04-29 14:18:45 -07:00
Lee, Jebum 90754dc74b fix: change method for alloc string to buffer (#777)
String.length is not appropriate for calculating buffer size
when non-alphabet letter is included in content.
Change the method Buffer.alloc to Buffer.from as directed by the nodejs document.
2019-04-29 14:18:45 -07:00
Jacopo Mangiavacchi f29963c89e feat: Add CSV Exporter (#757)
Adds CSV export provider
2019-04-29 14:18:45 -07:00
Tanner Barlow acbbc86151 fix: Fix display of tag color picker (#782)
Resolves issue of tag color picker not being shown on alt-click or color-click + edit button. Also adds several tests for increased test coverage of tagInput.tsx
2019-04-29 14:18:45 -07:00
Wallace Breza 921dbac155 feat: Active Learning Updates (#778)
Adds new active learning form
Moves active learning settings from project settings to here
Refactored and created activeLearningService
2019-04-29 14:18:45 -07:00
P.J. Little a2ef52c7a4 docs: updates to readme and changelog (#781)
Minor updates and corrections to the main readme and changelog.
2019-04-29 14:18:45 -07:00
Wallace Breza 25b4aa2dc8 Create CODE_OF_CONDUCT.md (#779)
Adds code of conduct
2019-04-29 14:18:45 -07:00
Wallace Breza 0429590bec doc: Add bug & feature templates (#780)
Adds bug and feature github templates
2019-04-29 14:18:45 -07:00
Wallace Breza 48805dcb85 test: Verify tag update/delete project actions 2019-04-29 14:18:45 -07:00
Wallace Breza 0b06d6ac5b test: Refactored editor page tests 2019-04-29 14:18:45 -07:00
Wallace Breza 3998b6efc8 fix: Refactored project tag/delete updates 2019-04-29 14:18:45 -07:00
Tanner Barlow 4a0dcb2905 Dummy commit to kick off build again 2019-04-29 14:18:45 -07:00
Tanner Barlow 354623ec21 Lint fixes 2019-04-29 14:18:45 -07:00
Tanner Barlow 8b34db5724 Clean up and docs 2019-04-29 14:18:45 -07:00
Tanner Barlow bbd83a4df5 Fix tag removal test for editor page 2019-04-29 14:18:45 -07:00
Tanner Barlow 5b4610b3d9 Rename tag function in editor page 2019-04-29 14:18:45 -07:00
Tanner Barlow 996a555333 Delete tag working 2019-04-29 14:18:45 -07:00
Tanner Barlow 8439574dc5 Saving assets in async foreach loop 2019-04-29 14:18:45 -07:00
Tanner Barlow 4193bc0e6a Register mixins and run async loop 2019-04-29 14:18:45 -07:00
Tanner Barlow f394ea3d10 Editor Page and canvas upates 2019-04-29 14:18:45 -07:00
Tanner Barlow 4f325dfe4b Split out project functions and asset functions into respective services 2019-04-29 14:18:45 -07:00
Tanner Barlow 2ef4e1387f Added confirm and strings for tag and rename operations 2019-04-29 14:18:45 -07:00
Tanner Barlow 1001528a16 Added tests for project service 2019-04-29 14:18:45 -07:00
Tanner Barlow 6974aef9d1 Project service functions 2019-04-29 14:18:45 -07:00
Tanner Barlow 37234ec2e9 refactor: Remove editor footer 2019-04-29 14:18:45 -07:00
Wallace Breza d6a059447d Fix: Enables selection of azure region for custom vision export (#765)
Adds ability to select azure region where your custom vision service is hosted.
Filters domain list by project type

Resolves #759, #770
2019-04-29 14:18:45 -07:00
Wallace Breza c10c971caf feat: CNTK Export Provider (#771)
Adds CNTK export provider into v2

Resolves #754
2019-04-29 14:18:45 -07:00
Wallace Breza 0fe63863b1 feat: Save partial project progress during project creation (#769)
This adds functionality to persist partial project information when creating a new project. Right now when creating a new connection inline within the create project flow and returning to the create project screen your partial project information is lost. Partial form progress is now saved into local storage and bound when returning to the form.

Resolves #758
2019-04-29 14:18:45 -07:00
Jacopo Mangiavacchi 39521f2b61 Fix ymax and rename Tensorflow nama everywhere (#763)
fix issue #760 [AB#18223]
2019-04-29 14:18:45 -07:00
103 arquivos alterados com 25731 adições e 975 exclusões
+32
Ver Arquivo
@@ -0,0 +1,32 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+20
Ver Arquivo
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+20 -2
Ver Arquivo
@@ -2,8 +2,26 @@
<!-- cl-start -->
# [2.1.0](https://github.com/Microsoft/VoTT/compare/v2.0.0...v2.1.0) (04-29-2019)
[GitHub Release](https://github.com/Microsoft/VoTT/releases/tag/v2.1.0)
- fix: Updates backwards compat & fixes cntk export image bug (#789)
- fix: Updates export options for pascalVOC rename (#788)
- fix: change method for alloc string to buffer (#777)
- feat: Add CSV Exporter (#757)
- fix: Fix display of tag color picker (#782)
- feat: Active Learning Updates (#778)
- doc: updates to readme and changelog (#781)
- doc: Adds CODE_OF_CONDUCT.md (#779)
- doc: Add bug & feature templates (#780)
- fix: Refactored project tag/delete updates (#764)
- fix: Enables selection of azure region for custom vision export (#765)
- feat: CNTK Export Provider (#771)
- feat: Save partial project progress during project creation (#769)
- fix: Fixes ymax and rename Tensorflow nama everywhere (#763)
# [2.0.0](https://github.com/Microsoft/VoTT/compare/v2.0.0-preview.3...v2.0.0) (04-12-2019)
[GitHub Release](https://github.com/Microsoft/VoTT/releases/tag/2.0.0)
[GitHub Release](https://github.com/Microsoft/VoTT/releases/tag/v2.0.0)
- doc: update v1/master reference (#748)
- ci: update pipeline for v2 flipover to master (#747)
@@ -36,7 +54,7 @@
- ci: update pipelines to work for all branches with prefix dev (#700)
# [2.0.0-preview.3](https://github.com/Microsoft/VoTT/compare/v2.0.0-preview.2...v2.0.0-preview.3) (03-20-2019)
[GitHub Release](https://github.com/Microsoft/VoTT/releases/tag/2.0.0-preview.3)
[GitHub Release](https://github.com/Microsoft/VoTT/releases/tag/v2.0.0-preview.3)
- ci: Clean up sonar cloud issues
- Remove height from root style (#694)
+76
Ver Arquivo
@@ -0,0 +1,76 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at opensource@microsoft.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq
+35 -25
Ver Arquivo
@@ -28,34 +28,37 @@ VoTT helps facilitate an end-to-end machine learning pipeline:
<!-- toc -->
* [Getting Started](#getting-started)
* [Download and install a release package for your platform (recommended)](#download-and-install-a-release-package-for-your-platform-recommended)
* [Build and run from source](#build-and-run-from-source)
* [V1 & V2](#v1--v2)
* [Where is V1](#where-is-v1)
* [V1 releases](#v1-releases)
* [Can I use my V1 project in V2](#can-i-use-my-v1-project-in-v2)
* [Using VoTT](#using-vott)
* [Creating Connections](#creating-connections)
* [Creating a New Project](#creating-a-new-project)
* [Project Settings](#project-settings)
* [Security Tokens](#security-tokens)
* [Labeling an Image](#labeling-an-image)
* [Labeling a Video](#labeling-a-video)
* [Exporting Labels](#exporting-labels)
* [Keyboard Shortcuts](#keyboard-shortcuts)
* [Tag Shortcuts](#tag-shortcuts)
* [Tag Ordering](#tag-ordering)
* [Tag Locking](#tag-locking)
* [Editor Shortcuts](#editor-shortcuts)
* [Collaborators](#collaborators)
* [Contributing to VoTT](#contributing-to-vott)
- [VoTT (Visual Object Tagging Tool)](#vott-visual-object-tagging-tool)
- [Table of Contents](#table-of-contents)
- [Getting Started](#getting-started)
- [Download and install a release package for your platform (recommended)](#download-and-install-a-release-package-for-your-platform-recommended)
- [Build and run from source](#build-and-run-from-source)
- [Run as Web Application](#run-as-web-application)
- [V1 & V2](#v1--v2)
- [Where is V1](#where-is-v1)
- [V1 releases](#v1-releases)
- [V1 projects in V2](#v1-projects-in-v2)
- [Using VoTT](#using-vott)
- [Creating Connections](#creating-connections)
- [Creating a New Project](#creating-a-new-project)
- [Project Settings](#project-settings)
- [Security Tokens](#security-tokens)
- [Labeling an Image](#labeling-an-image)
- [Labeling a Video](#labeling-a-video)
- [Exporting Labels](#exporting-labels)
- [Keyboard Shortcuts](#keyboard-shortcuts)
- [Tag Ordering](#tag-ordering)
- [Tag Locking](#tag-locking)
- [Editor Shortcuts](#editor-shortcuts)
- [Mouse Controls](#mouse-controls)
- [Collaborators](#collaborators)
- [Contributing to VoTT](#contributing-to-vott)
<!-- tocstop -->
## Getting Started
VoTT can be installed as a native application or run from source.
VoTT can be installed as a native application or run from source. VoTT is also available as a [stand-alone Web application](https://vott.z5.web.core.windows.net) and can be used in any modern Web browser.
### Download and install a release package for your platform (recommended)
@@ -71,11 +74,16 @@ VoTT requires [NodeJS (>= 10.x, Dubnium) and NPM](https://github.com/nodejs/Rele
npm ci
npm start
```
> **IMPORTANT**
>
> When running locally with `npm`, both the electron and the browser versions of the application will start. One major difference is that the electron version can access the local file system.
### Run as Web Application
Using a modern Web browser, VoTT can be loaded from: [https://vott.z5.web.core.windows.net](https://vott.z5.web.core.windows.net)
As noted above, the Web version of VoTT *cannot* access the local file system; all assets must be imported/exported through a Cloud project.
## V1 & V2
VoTT V2 is a refactor and refresh of the original Electron-based application. As the usage and demand for VoTT grew, `V2` was started as an initiative to improve and make VoTT more extensible and maintainable. In addition, `V2` uses more modern development frameworks and patterns (React, Redux) and is authored in TypeScript.
@@ -187,8 +195,10 @@ Tagging and drawing regions is not possible while the video is playing.
Once assets have been labeled, they can be exported into a variety of formats:
* [Azure Custom Vision Service](https://azure.microsoft.com/en-us/services/cognitive-services/custom-vision-service/)
* [Microsoft Cognitive Toolkit (CNTK)](https://github.com/Microsoft/CNTK)
* TensorFlow (Pascal VOC and TFRecords)
* VoTT (generic JSON schema)
* Comma Separated Values (CSV)
In addition, users may choose to export
@@ -238,7 +248,7 @@ VOTT allows you to fine tune the bounding boxes using the arrow keys in a few di
* Ctrl + Alt + Arrowkey - Shrink Region
* Ctrl + Shift + Arrowkey - Expand Region
The slide viewer can be navigated from the keyboard as follows:
The slide viewer can be navigated from the keyboard as follows:
* W or ArrowUp - Previous Asset
* S or ArrowDown - Next Asset
+1
Ver Arquivo
@@ -0,0 +1 @@
[{"name":"/m/01g317","id":1,"displayName":"person"},{"name":"/m/0199g","id":2,"displayName":"bicycle"},{"name":"/m/0k4j","id":3,"displayName":"car"},{"name":"/m/04_sv","id":4,"displayName":"motorcycle"},{"name":"/m/05czz6l","id":5,"displayName":"airplane"},{"name":"/m/01bjv","id":6,"displayName":"bus"},{"name":"/m/07jdr","id":7,"displayName":"train"},{"name":"/m/07r04","id":8,"displayName":"truck"},{"name":"/m/019jd","id":9,"displayName":"boat"},{"name":"/m/015qff","id":10,"displayName":"traffic light"},{"name":"/m/01pns0","id":11,"displayName":"fire hydrant"},{"name":"/m/02pv19","id":13,"displayName":"stop sign"},{"name":"/m/015qbp","id":14,"displayName":"parking meter"},{"name":"/m/0cvnqh","id":15,"displayName":"bench"},{"name":"/m/015p6","id":16,"displayName":"bird"},{"name":"/m/01yrx","id":17,"displayName":"cat"},{"name":"/m/0bt9lr","id":18,"displayName":"dog"},{"name":"/m/03k3r","id":19,"displayName":"horse"},{"name":"/m/07bgp","id":20,"displayName":"sheep"},{"name":"/m/01xq0k1","id":21,"displayName":"cow"},{"name":"/m/0bwd_0j","id":22,"displayName":"elephant"},{"name":"/m/01dws","id":23,"displayName":"bear"},{"name":"/m/0898b","id":24,"displayName":"zebra"},{"name":"/m/03bk1","id":25,"displayName":"giraffe"},{"name":"/m/01940j","id":27,"displayName":"backpack"},{"name":"/m/0hnnb","id":28,"displayName":"umbrella"},{"name":"/m/080hkjn","id":31,"displayName":"handbag"},{"name":"/m/01rkbr","id":32,"displayName":"tie"},{"name":"/m/01s55n","id":33,"displayName":"suitcase"},{"name":"/m/02wmf","id":34,"displayName":"frisbee"},{"name":"/m/071p9","id":35,"displayName":"skis"},{"name":"/m/06__v","id":36,"displayName":"snowboard"},{"name":"/m/018xm","id":37,"displayName":"sports ball"},{"name":"/m/02zt3","id":38,"displayName":"kite"},{"name":"/m/03g8mr","id":39,"displayName":"baseball bat"},{"name":"/m/03grzl","id":40,"displayName":"baseball glove"},{"name":"/m/06_fw","id":41,"displayName":"skateboard"},{"name":"/m/019w40","id":42,"displayName":"surfboard"},{"name":"/m/0dv9c","id":43,"displayName":"tennis racket"},{"name":"/m/04dr76w","id":44,"displayName":"bottle"},{"name":"/m/09tvcd","id":46,"displayName":"wine glass"},{"name":"/m/08gqpm","id":47,"displayName":"cup"},{"name":"/m/0dt3t","id":48,"displayName":"fork"},{"name":"/m/04ctx","id":49,"displayName":"knife"},{"name":"/m/0cmx8","id":50,"displayName":"spoon"},{"name":"/m/04kkgm","id":51,"displayName":"bowl"},{"name":"/m/09qck","id":52,"displayName":"banana"},{"name":"/m/014j1m","id":53,"displayName":"apple"},{"name":"/m/0l515","id":54,"displayName":"sandwich"},{"name":"/m/0cyhj_","id":55,"displayName":"orange"},{"name":"/m/0hkxq","id":56,"displayName":"broccoli"},{"name":"/m/0fj52s","id":57,"displayName":"carrot"},{"name":"/m/01b9xk","id":58,"displayName":"hot dog"},{"name":"/m/0663v","id":59,"displayName":"pizza"},{"name":"/m/0jy4k","id":60,"displayName":"donut"},{"name":"/m/0fszt","id":61,"displayName":"cake"},{"name":"/m/01mzpv","id":62,"displayName":"chair"},{"name":"/m/02crq1","id":63,"displayName":"couch"},{"name":"/m/03fp41","id":64,"displayName":"potted plant"},{"name":"/m/03ssj5","id":65,"displayName":"bed"},{"name":"/m/04bcr3","id":67,"displayName":"dining table"},{"name":"/m/09g1w","id":70,"displayName":"toilet"},{"name":"/m/07c52","id":72,"displayName":"tv"},{"name":"/m/01c648","id":73,"displayName":"laptop"},{"name":"/m/020lf","id":74,"displayName":"mouse"},{"name":"/m/0qjjc","id":75,"displayName":"remote"},{"name":"/m/01m2v","id":76,"displayName":"keyboard"},{"name":"/m/050k8","id":77,"displayName":"cell phone"},{"name":"/m/0fx9l","id":78,"displayName":"microwave"},{"name":"/m/029bxz","id":79,"displayName":"oven"},{"name":"/m/01k6s3","id":80,"displayName":"toaster"},{"name":"/m/0130jx","id":81,"displayName":"sink"},{"name":"/m/040b_t","id":82,"displayName":"refrigerator"},{"name":"/m/0bt_c3","id":84,"displayName":"book"},{"name":"/m/01x3z","id":85,"displayName":"clock"},{"name":"/m/02s195","id":86,"displayName":"vase"},{"name":"/m/01lsmm","id":87,"displayName":"scissors"},{"name":"/m/0kmg4","id":88,"displayName":"teddy bear"},{"name":"/m/03wvsk","id":89,"displayName":"hair drier"},{"name":"/m/012xff","id":90,"displayName":"toothbrush"}]
Arquivo binário não exibido.
Arquivo binário não exibido.
Arquivo binário não exibido.
Arquivo binário não exibido.
Arquivo binário não exibido.
Diferenças do arquivo suprimidas por serem muito extensas Carregar Diff
+2
Ver Arquivo
@@ -20,3 +20,5 @@ linux:
- snap
publish: null
electronVersion: 3.0.13
extraFiles:
- "cocoSSDModel"
+144 -17
Ver Arquivo
@@ -1,6 +1,6 @@
{
"name": "vott",
"version": "2.0.0",
"version": "2.1.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -1017,6 +1017,55 @@
"loader-utils": "^1.1.0"
}
},
"@tensorflow/tfjs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-1.0.3.tgz",
"integrity": "sha512-tF6GcjO2KBYlPPiS7o4X+D3oASXJcWAYaZA13GCYp5cXAui0ncHxpC85kmNQlp2HEVmcE82BJz/1uUtkNxxQpw==",
"requires": {
"@tensorflow/tfjs-converter": "1.0.3",
"@tensorflow/tfjs-core": "1.0.3",
"@tensorflow/tfjs-data": "1.0.3",
"@tensorflow/tfjs-layers": "1.0.3"
}
},
"@tensorflow/tfjs-converter": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-1.0.3.tgz",
"integrity": "sha512-vrGvVrPekhTOwMGsomcpcjw0ZUep6xhI8DQQoFXHjBcprt9bFO2hHMdAmYpqafcJ7KVMylbK4h2LJrsBI2zDgQ=="
},
"@tensorflow/tfjs-core": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-1.0.3.tgz",
"integrity": "sha512-2UbjMQkmrykIIZuoRfmDPrtWm+6fdQRlYLCUJdiOIooeu/q4nye587HM1qKcdZosGPZTW6VvX+4VIVieYn5i0A==",
"requires": {
"@types/seedrandom": "2.4.27",
"@types/webgl-ext": "0.0.30",
"@types/webgl2": "0.0.4",
"seedrandom": "2.4.3"
}
},
"@tensorflow/tfjs-data": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-data/-/tfjs-data-1.0.3.tgz",
"integrity": "sha512-WFjYU2pWNZ0TZaJ7rN18GD/wOTVe6rBGxvSwZxIhEVIbwKXaKXFa9V4aGp4QBG9AXHIA89SjmGSGPxfsC015hQ==",
"requires": {
"@types/node-fetch": "^2.1.2",
"node-fetch": "~2.1.2",
"seedrandom": "~2.4.3"
},
"dependencies": {
"node-fetch": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz",
"integrity": "sha1-q4hOjn5X44qUR1POxwb3iNF2i7U="
}
}
},
"@tensorflow/tfjs-layers": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-layers/-/tfjs-layers-1.0.3.tgz",
"integrity": "sha512-7VdvQb0ft7TrWAbBy7HI+p420KX9rblYYACZ7/BzvzsikfEOdEL90WxrZDjZ167rYN/KvqC/haGcmhW/dYU3MA=="
},
"@types/axios": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.0.tgz",
@@ -1074,6 +1123,15 @@
"integrity": "sha512-NVQEMviDWjuen3UW+mU1J6fZ0WhOfG1yRce/2OTcbaz+fgmTw2cahx6N2wh0Yl+a+hg2UZj/oElZmtULWyGIsA==",
"dev": true
},
"@types/json2csv": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@types/json2csv/-/json2csv-4.4.0.tgz",
"integrity": "sha512-24S6hQGGsOZxTXbRyKvNaV5k882XTo9RX/LH6+RtVtimFNE2J0T/LWlru6BeEssByVA9/ZLif1PLk/8X8/qPCQ==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/lodash": {
"version": "4.14.120",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.120.tgz",
@@ -1087,8 +1145,15 @@
"@types/node": {
"version": "10.12.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.7.tgz",
"integrity": "sha512-Zh5Z4kACfbeE8aAOYh9mqotRxaZMro8MbBQtR8vEXOMiZo2rGEh2LayJijKdlu48YnS6y2EFU/oo2NCe5P6jGw==",
"dev": true
"integrity": "sha512-Zh5Z4kACfbeE8aAOYh9mqotRxaZMro8MbBQtR8vEXOMiZo2rGEh2LayJijKdlu48YnS6y2EFU/oo2NCe5P6jGw=="
},
"@types/node-fetch": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.1.7.tgz",
"integrity": "sha512-TZozHCDVrs0Aj1B9ZR5F4Q9MknDNcVd+hO5lxXOCzz07ELBey6s1gMUSZHUYHlPfRFKJFXiTnNuD7ePiI6S4/g==",
"requires": {
"@types/node": "*"
}
},
"@types/prop-types": {
"version": "15.5.8",
@@ -1230,6 +1295,11 @@
"redux": "^4.0.0"
}
},
"@types/seedrandom": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz",
"integrity": "sha1-nbVjk33YaRX2kJK8QyWdL0hXjkE="
},
"@types/snapsvg": {
"version": "0.4.35",
"resolved": "https://registry.npmjs.org/@types/snapsvg/-/snapsvg-0.4.35.tgz",
@@ -1243,6 +1313,16 @@
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.2.tgz",
"integrity": "sha512-42zEJkBpNfMEAvWR5WlwtTH22oDzcMjFsL9gDGExwF8X8WvAiw7Vwop7hPw03QT8TKfec83LwbHj6SvpqM4ELQ=="
},
"@types/webgl-ext": {
"version": "0.0.30",
"resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
"integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg=="
},
"@types/webgl2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@types/webgl2/-/webgl2-0.0.4.tgz",
"integrity": "sha512-PACt1xdErJbMUOUweSrbVM7gSIYm1vTncW2hF6Os/EeWi6TXYAYMPp+8v6rzHmypE5gHrxaxZNXgMkJVIdZpHw=="
},
"@webassemblyjs/ast": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.7.6.tgz",
@@ -7239,7 +7319,8 @@
"ansi-regex": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
"optional": true
},
"aproba": {
"version": "1.2.0",
@@ -7282,7 +7363,8 @@
"code-point-at": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
"optional": true
},
"concat-map": {
"version": "0.0.1",
@@ -7293,7 +7375,8 @@
"console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@@ -7423,6 +7506,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -7552,7 +7636,8 @@
"number-is-nan": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
"optional": true
},
"object-assign": {
"version": "4.1.1",
@@ -7650,7 +7735,8 @@
"safe-buffer": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -7686,6 +7772,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -7705,6 +7792,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -7748,12 +7836,14 @@
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"optional": true
},
"yallist": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz",
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k="
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=",
"optional": true
}
}
},
@@ -9372,6 +9462,17 @@
"requires": {
"node-fetch": "^1.0.1",
"whatwg-fetch": ">=0.10.0"
},
"dependencies": {
"node-fetch": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
"integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
"requires": {
"encoding": "^0.1.11",
"is-stream": "^1.0.1"
}
}
}
},
"isstream": {
@@ -10142,6 +10243,11 @@
"topo": "2.x.x"
}
},
"jpeg-js": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.3.4.tgz",
"integrity": "sha512-6IzjQxvnlT8UlklNmDXIJMWxijULjqGrzgqc0OG7YadZdvm7KPQ1j0ehmQQHckgEWOfgpptzcnWgESovxudpTA=="
},
"jquery": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz",
@@ -10260,6 +10366,16 @@
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
},
"json2csv": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/json2csv/-/json2csv-4.5.0.tgz",
"integrity": "sha512-SfWprYRawoBsEHZyb1NvZz2qJpuLRDlSexHqtnHxFEFvzK83zgm7Bftq2miBODjRQG0O7PaHC5271Hfbu10P+w==",
"requires": {
"commander": "^2.15.1",
"jsonparse": "^1.3.1",
"lodash.get": "^4.4.2"
}
},
"json3": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz",
@@ -10283,6 +10399,11 @@
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
"integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM="
},
"jsonparse": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
"integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA="
},
"jsprim": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
@@ -10518,6 +10639,11 @@
"integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=",
"dev": true
},
"lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
},
"lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
@@ -11253,13 +11379,9 @@
}
},
"node-fetch": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
"integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
"requires": {
"encoding": "^0.1.11",
"is-stream": "^1.0.1"
}
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.3.0.tgz",
"integrity": "sha512-MOd8pV3fxENbryESLgVIeaGKrdl+uaYhCSSVkjeOb/31/njTpcis5aWfdqgNlHIrKOLRbMnfPINPOML2CIFeXA=="
},
"node-forge": {
"version": "0.7.5",
@@ -16369,6 +16491,11 @@
}
}
},
"seedrandom": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz",
"integrity": "sha1-JDhQTa0zkXMUv/GKxNeU8W1qrsw="
},
"select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
+6 -1
Ver Arquivo
@@ -1,6 +1,6 @@
{
"name": "vott",
"version": "2.0.0",
"version": "2.1.0",
"author": {
"name": "Microsoft",
"url": "https://github.com/Microsoft/VoTT"
@@ -16,6 +16,7 @@
"main": "build/main.js",
"dependencies": {
"@azure/storage-blob": "^10.3.0",
"@tensorflow/tfjs": "^1.0.3",
"@types/snapsvg": "^0.4.35",
"axios": "^0.18.0",
"bootstrap": "^4.1.3",
@@ -23,8 +24,11 @@
"crypto-js": "^3.1.9-1",
"dotenv": "^7.0.0",
"google-protobuf": "^3.6.1",
"jpeg-js": "^0.3.4",
"json2csv": "^4.5.0",
"lodash": "^4.17.11",
"md5.js": "^1.3.5",
"node-fetch": "^2.3.0",
"node-int64": "^0.4.0",
"rc-align": "^2.4.5",
"rc-checkbox": "^2.1.6",
@@ -95,6 +99,7 @@
"@types/dotenv": "^6.1.0",
"@types/enzyme": "^3.1.15",
"@types/jest": "23.3.9",
"@types/json2csv": "^4.4.0",
"@types/node": "10.12.7",
"@types/react": "16.7.6",
"@types/react-dom": "16.0.9",
+1 -74
Ver Arquivo
@@ -10,17 +10,7 @@ describe("Html File Reader", () => {
beforeEach(() => {
assetTestCache.clear();
document.createElement = jest.fn((elementType) => {
switch (elementType) {
case "img":
return mockImage();
case "video":
return mockVideo();
case "canvas":
return mockCanvas();
}
});
MockFactory.mockElement(assetTestCache);
});
it("Resolves promise after successfully reading file", async () => {
@@ -234,67 +224,4 @@ describe("Html File Reader", () => {
await expect(HtmlFileReader.getAssetFrameImage(videoErrorFrame)).rejects.not.toBeNull();
});
});
const mockImage = jest.fn(() => {
const element: any = {
naturalWidth: 0,
naturalHeight: 0,
onload: jest.fn(),
};
setImmediate(() => {
const asset = assetTestCache.get(element.src);
element.naturalWidth = asset.size.width;
element.naturalHeight = asset.size.height;
element.onload();
});
return element;
});
const mockVideo = jest.fn(() => {
const element: any = {
src: "",
duration: 0,
currentTime: 0,
videoWidth: 0,
videoHeight: 0,
onloadedmetadata: jest.fn(),
onseeked: jest.fn(),
onerror: jest.fn(),
};
setImmediate(() => {
const asset = assetTestCache.get(element.src);
if (asset.name.toLowerCase().indexOf("error") > -1) {
element.onerror("An error occurred loading the video");
} else {
element.videoWidth = asset.size.width;
element.videoHeight = asset.size.height;
element.currentTime = asset.timestamp;
element.onloadedmetadata();
element.onseeked();
}
});
return element;
});
const mockCanvas = jest.fn(() => {
const canvas: any = {
width: 0,
height: 0,
getContext: jest.fn(() => {
return {
drawImage: jest.fn(),
};
}),
toBlob: jest.fn((callback) => {
callback(new Blob(["Binary image data"]));
}),
};
return canvas;
});
});
+89 -13
Ver Arquivo
@@ -143,6 +143,7 @@ export const english: IAppStrings = {
warnings: {
existingName: "Tag name already exists. Choose another name",
emptyName: "Cannot have an empty tag name",
unknownTagName: "Unknown",
},
toolbar: {
add: "Add new tag",
@@ -178,6 +179,10 @@ export const english: IAppStrings = {
title: "Account Name",
description: "",
},
accountKey: {
title: "Account Key",
description: "",
},
containerName: {
title: "Container Name",
description: "",
@@ -231,6 +236,7 @@ export const english: IAppStrings = {
nextAsset: "Next Asset",
saveProject: "Save Project",
exportProject: "Export Project",
activeLearning: "Active Learning",
},
videoPlayer: {
previousTaggedFrame: {
@@ -256,6 +262,15 @@ export const english: IAppStrings = {
apply: "Apply Tag with Hot Key",
lock: "Lock Tag with Hot Key",
},
rename: {
title: "Rename Tag",
confirmation: "Are you sure you want to rename this tag? It will be renamed throughout all assets",
},
delete: {
title: "Delete Tag",
confirmation: "Are you sure you want to delete this tag? It will be deleted throughout all assets \
and any regions where this is the only tag will also be deleted",
},
},
canvas: {
removeAllRegions: {
@@ -266,8 +281,8 @@ export const english: IAppStrings = {
messages: {
enforceTaggedRegions: {
title: "Invalid region(s) detected",
// tslint:disable-next-line:max-line-length
description: "1 or more regions have not been tagged. Ensure all regions are tagged before continuing to next asset.",
description: "1 or more regions have not been tagged. Ensure all regions are tagged before \
continuing to next asset.",
},
},
},
@@ -287,23 +302,43 @@ export const english: IAppStrings = {
tagged: "Only tagged Assets",
},
},
},
},
vottJson: {
displayName: "VoTT JSON",
properties: {
testTrainSplit: {
title: "Test / Train Split",
description: "The test train split to use for exported data",
},
includeImages: {
title: "Include Images",
description: "Whether or not to include binary image assets in target connection",
},
},
},
vottJson: {
displayName: "VoTT JSON",
},
azureCV: {
displayName: "Azure Custom Vision Service",
regions: {
australiaEast: "Australia East",
centralIndia: "Central India",
eastUs: "East US",
eastUs2: "East US 2",
japanEast: "Japan East",
northCentralUs: "North Central US",
northEurope: "North Europe",
southCentralUs: "South Central US",
southeastAsia: "Southeast Asia",
ukSouth: "UK South",
westUs2: "West US 2",
westEurope: "West Europe",
},
properties: {
apiKey: {
title: "API Key",
},
region: {
title: "Region",
description: "The Azure region where your service is deployed",
},
classificationType: {
title: "Classification Type",
options: {
@@ -342,17 +377,19 @@ export const english: IAppStrings = {
tfRecords: {
displayName: "Tensorflow Records",
},
tfPascalVoc: {
displayName: "Tensorflow Pascal VOC",
testTrainSplit: {
title: "Test / Train Split",
description: "The test train split to use for exported data",
},
pascalVoc: {
displayName: "Pascal VOC",
exportUnassigned: {
title: "Export Unassigned",
description: "Whether or not to include unassigned tags in exported data",
},
},
cntk: {
displayName: "Microsoft Cognitive Toolkit (CNTK)",
},
csv: {
displayName: "Comma Separated Values (CSV)",
},
},
messages: {
saveSuccess: "Successfully saved export settings",
@@ -360,6 +397,40 @@ export const english: IAppStrings = {
},
activeLearning: {
title: "Active Learning",
form: {
properties: {
modelPathType: {
title: "Model Provider",
description: "Where to load the training model from",
options: {
preTrained: "Pre-trained Coco SSD",
customFilePath: "Custom (File path)",
customWebUrl: "Custom (Url)",
},
},
autoDetect: {
title: "Auto Detect",
description: "Whether or not to automatically make predictions as you navigate between assets",
},
modelPath: {
title: "Model path",
description: "Select a model from your local file system",
},
modelUrl: {
title: "Model URL",
description: "Load your model from a public web URL",
},
predictTag: {
title: "Predict Tag",
description: "Whether or not to automatically include tags in predictions",
},
},
},
messages: {
loadingModel: "Loading active learning model...",
errorLoadModel: "Error loading active learning model",
saveSuccess: "Successfully saved active learning settings",
},
},
profile: {
settings: "Profile Settings",
@@ -413,5 +484,10 @@ export const english: IAppStrings = {
title: "Error exporting project",
message: "Project is missing export format. Please select an export format in the export setting page.",
},
activeLearningPredictionError: {
title: "Active Learning Error",
message: "An error occurred while predicting regions in the current asset. \
Please verify your active learning configuration and try again",
},
},
};
+93 -15
Ver Arquivo
@@ -60,8 +60,8 @@ export const spanish: IAppStrings = {
},
securityTokens: {
title: "Tokens de seguridad",
// tslint:disable-next-line:max-line-length
description: "Los tokens de seguridad se utilizan para cifrar datos confidenciales dentro de la configuración del proyecto",
description: "Los tokens de seguridad se utilizan para cifrar datos confidenciales \
dentro de la configuración del proyecto",
},
version: {
description: "Versión:",
@@ -144,6 +144,7 @@ export const spanish: IAppStrings = {
warnings: {
existingName: "Nombre de etiqueta ya existe. Elige otro nombre",
emptyName: "El nombre de etiqueta no puede ser vacío",
unknownTagName: "Desconocido",
},
toolbar: {
add: "Agregar nueva etiqueta",
@@ -180,6 +181,10 @@ export const spanish: IAppStrings = {
title: "Nombre de cuenta",
description: "",
},
accountKey: {
title: "Clave de cuenta",
description: "",
},
containerName: {
title: "Nombre del contenedor",
description: "",
@@ -233,6 +238,7 @@ export const spanish: IAppStrings = {
nextAsset: "Siguiente activo",
saveProject: "Guardar Proyecto",
exportProject: "Exprtar Proyecto",
activeLearning: "Aprendizaje Activo",
},
videoPlayer: {
previousTaggedFrame: {
@@ -258,6 +264,16 @@ export const spanish: IAppStrings = {
apply: "Aplicar etiqueta con tecla de acceso rápido",
lock: "Bloquear etiqueta con tecla de acceso rápido",
},
rename: {
title: "Cambiar el nombre de la etiqueta",
confirmation: "¿Está seguro que quiere cambiar el nombre de esta etiqueta? \
Será cambiada en todos los activos",
},
delete: {
title: "Delete Tag",
confirmation: "¿Está seguro que quiere borrar esta etiqueta? Será borrada en todos \
los activos y en las regiones donde esta etiqueta sea la única, la region también será borrada",
},
},
canvas: {
removeAllRegions: {
@@ -268,8 +284,8 @@ export const spanish: IAppStrings = {
messages: {
enforceTaggedRegions: {
title: "Las regiones no válidas detectadas",
// tslint:disable-next-line:max-line-length
description: "1 o más regiones no se han etiquetado. Por favor, etiquete todas las regiones antes de continuar con el siguiente activo.",
description: "1 o más regiones no se han etiquetado. \
Por favor, etiquete todas las regiones antes de continuar con el siguiente activo.",
},
},
},
@@ -289,23 +305,43 @@ export const spanish: IAppStrings = {
tagged: "Solo activos etiquetados",
},
},
},
},
vottJson: {
displayName: "VoTT JSON",
properties: {
testTrainSplit: {
title: "La división para entrenar y comprobar",
description: "La división de datos para utilizar entre el entrenamiento y la comprobación",
},
includeImages: {
title: "Incluir imágenes",
description: "Si desea o no incluir activos de imagen binaria en la conexión de destino",
},
},
},
vottJson: {
displayName: "VoTT JSON",
},
azureCV: {
displayName: "Servicio de Visión Personalizada Azure",
regions: {
australiaEast: "Australia este",
centralIndia: "Centro de la India",
eastUs: "Este de EE.",
eastUs2: "Este US 2",
japanEast: "Japón este",
northCentralUs: "Centro norte de EE.",
northEurope: "Europa del norte",
southCentralUs: "Centro sur de EE.",
southeastAsia: "Sudeste asiático",
ukSouth: "UK sur",
westUs2: "West US 2",
westEurope: "Europa occidental",
},
properties: {
apiKey: {
title: "Clave de API",
},
region: {
title: "Región",
description: "La región de Azure donde se implementa el servicio",
},
classificationType: {
title: "Tipo de clasificación",
options: {
@@ -344,17 +380,19 @@ export const spanish: IAppStrings = {
tfRecords: {
displayName: "Registros de Tensorflow",
},
tfPascalVoc: {
displayName: "Tensorflow Pascal VOC",
testTrainSplit: {
title: "Prueba/tren Split",
description: "La división del tren de prueba que se utilizará para los datos exportados",
},
pascalVoc: {
displayName: "Pascal VOC",
exportUnassigned: {
title: "Exportar sin asignar",
description: "Si se incluyen o no etiquetas no asignadas en los datos exportados",
},
},
cntk: {
displayName: "Microsoft Cognitive Toolkit (CNTK)",
},
csv: {
displayName: "Los valores separados por comas (CSV)",
},
},
messages: {
saveSuccess: "Configuración de exportación guardada correctamente",
@@ -362,6 +400,41 @@ export const spanish: IAppStrings = {
},
activeLearning: {
title: "Aprendizaje Activo",
form: {
properties: {
modelPathType: {
title: "Proveedor del modelo",
description: "Fuente desde la cual cargar el modelo",
options: {
preTrained: "SSD de coco pre-entrenado",
customFilePath: "Personalizado (ruta de archivo)",
customWebUrl: "Personalizado (URL)",
},
},
autoDetect: {
title: "Detección automática",
description: "Si desea o no realizar automáticamente predicciones a \
medida que navega entre activos",
},
modelPath: {
title: "Ruta de modelo",
description: "Seleccione un modelo de su sistema de archivos local",
},
modelUrl: {
title: "URL del modelo",
description: "Cargue el modelo desde una URL web pública",
},
predictTag: {
title: "Predecir etiqueta",
description: "Si se incluirán o no automáticamente las etiquetas en las predicciones",
},
},
},
messages: {
loadingModel: "Cargando modelo...",
errorLoadModel: "Error al cargar el modelo",
saveSuccess: "La configuración de aprendizaje activa se ha guardada correctamente",
},
},
profile: {
settings: "Configuración de Perfíl",
@@ -417,5 +490,10 @@ export const spanish: IAppStrings = {
message: `Proyecto falta el formato de exportación. Seleccione un formato de exportación en la página
de configuración de exportación.`,
},
activeLearningPredictionError: {
title: "Error de aprendizaje",
message: "Se ha producido un error al predecir regiones en el activo actual. \
Compruebe la configuración de aprendizaje activa y vuelva a intentarlo",
},
},
};
+123 -12
Ver Arquivo
@@ -3,7 +3,7 @@ import {
AssetState, AssetType, IApplicationState, IAppSettings, IAsset, IAssetMetadata,
IConnection, IExportFormat, IProject, ITag, StorageType, ISecurityToken,
EditorMode, IAppError, IProjectVideoSettings, ErrorCode,
IPoint, IRegion, RegionType,
IPoint, IRegion, RegionType, ModelPathType,
} from "../models/applicationState";
import { IV1Project, IV1Region } from "../models/v1Models";
import { ExportAssetState } from "../providers/export/exportProvider";
@@ -33,6 +33,7 @@ import { SelectionMode } from "vott-ct/lib/js/CanvasTools/Interface/ISelectorSet
import { IKeyboardBindingProps } from "../react/components/common/keyboardBinding/keyboardBinding";
import { KeyEventType } from "../react/components/common/keyboardManager/keyboardManager";
import { IKeyboardRegistrations } from "../react/components/common/keyboardManager/keyboardRegistrationManager";
import { IActiveLearningPageProps } from "../react/components/pages/activeLearning/activeLearningPage";
export default class MockFactory {
@@ -283,6 +284,13 @@ export default class MockFactory {
targetConnection: connection,
tags: MockFactory.createTestTags(tagCount),
videoSettings: MockFactory.createVideoSettings(),
activeLearningSettings: {
modelPathType: ModelPathType.Coco,
modelPath: "",
modelUrl: "",
autoDetect: false,
predictTag: false,
},
autoSave: true,
};
}
@@ -630,14 +638,14 @@ export default class MockFactory {
/**
* Creates array of IExportProviderRegistrationOptions for the different providers
* vottJson, tensorFlowPascalVOC, azureCustomVision
* vottJson, PascalVOC, azureCustomVision, csv
*/
public static createExportProviderRegistrations(): IExportProviderRegistrationOptions[] {
const registrations: IExportProviderRegistrationOptions[] = [];
registrations.push(MockFactory.createExportProviderRegistration("vottJson"));
registrations.push(MockFactory.createExportProviderRegistration("tensorFlowPascalVOC"));
registrations.push(MockFactory.createExportProviderRegistration("pascalVOC"));
registrations.push(MockFactory.createExportProviderRegistration("azureCustomVision"));
registrations.push(MockFactory.createExportProviderRegistration("csv"));
return registrations;
}
@@ -810,14 +818,16 @@ export default class MockFactory {
*/
public static projectActions(): IProjectActions {
return {
loadProject: jest.fn((project: IProject) => Promise.resolve()),
saveProject: jest.fn((project: IProject) => Promise.resolve()),
deleteProject: jest.fn((project: IProject) => Promise.resolve()),
loadProject: jest.fn(() => Promise.resolve()),
saveProject: jest.fn(() => Promise.resolve()),
deleteProject: jest.fn(() => Promise.resolve()),
closeProject: jest.fn(() => Promise.resolve()),
loadAssets: jest.fn((project: IProject) => Promise.resolve()),
exportProject: jest.fn((project: IProject) => Promise.resolve()),
loadAssetMetadata: jest.fn((project: IProject, asset: IAsset) => Promise.resolve()),
saveAssetMetadata: jest.fn((project: IProject, assetMetadata: IAssetMetadata) => Promise.resolve()),
loadAssets: jest.fn(() => Promise.resolve()),
exportProject: jest.fn(() => Promise.resolve()),
loadAssetMetadata: jest.fn(() => Promise.resolve()),
saveAssetMetadata: jest.fn(() => Promise.resolve()),
updateProjectTag: jest.fn(() => Promise.resolve()),
deleteProjectTag: jest.fn(() => Promise.resolve()),
};
}
@@ -884,6 +894,21 @@ export default class MockFactory {
};
}
/**
* Creates fake IActiveLearningPageProps
* @param projectId Current project ID
*/
public static activeLearningProps(projectId?: string): IActiveLearningPageProps {
return {
actions: (projectActions as any) as IProjectActions,
history: MockFactory.history(),
location: MockFactory.location(),
match: MockFactory.match(projectId, "active-learning"),
project: null,
recentProjects: MockFactory.createTestProjects(),
};
}
/**
* Creates fake IEditorPageProps
* @param projectId Current project ID
@@ -1010,6 +1035,93 @@ export default class MockFactory {
};
}
public static mockElement(assetTestCache: Map<string, IAsset>) {
document.createElement = jest.fn((elementType) => {
switch (elementType) {
case "img":
const mockImage = MockFactory.mockImage(assetTestCache);
return mockImage();
case "video":
const mockVideo = MockFactory.mockVideo(assetTestCache);
return mockVideo();
case "canvas":
const mockCanvas = MockFactory.mockCanvas();
return mockCanvas();
}
});
}
public static mockImage(assetTestCache: Map<string, IAsset>) {
return jest.fn(() => {
const element: any = {
naturalWidth: 0,
naturalHeight: 0,
onload: jest.fn(),
};
setImmediate(() => {
const asset = assetTestCache.get(element.src);
if (asset) {
element.naturalWidth = asset.size.width;
element.naturalHeight = asset.size.height;
}
element.onload();
});
return element;
});
}
public static mockVideo(assetTestCache: Map<string, IAsset>) {
return jest.fn(() => {
const element: any = {
src: "",
duration: 0,
currentTime: 0,
videoWidth: 0,
videoHeight: 0,
onloadedmetadata: jest.fn(),
onseeked: jest.fn(),
onerror: jest.fn(),
};
setImmediate(() => {
const asset = assetTestCache.get(element.src);
if (asset.name.toLowerCase().indexOf("error") > -1) {
element.onerror("An error occurred loading the video");
} else {
element.videoWidth = asset.size.width;
element.videoHeight = asset.size.height;
element.currentTime = asset.timestamp;
element.onloadedmetadata();
element.onseeked();
}
});
return element;
});
}
public static mockCanvas() {
return jest.fn(() => {
const canvas: any = {
width: 800,
height: 600,
getContext: jest.fn(() => {
return {
drawImage: jest.fn(),
};
}),
toBlob: jest.fn((callback) => {
callback(new Blob(["Binary image data"]));
}),
};
return canvas;
});
}
private static pageProps(projectId: string, method: string) {
return {
project: null,
@@ -1091,5 +1203,4 @@ export default class MockFactory {
return StorageType.Other;
}
}
}
+81 -10
Ver Arquivo
@@ -154,6 +154,7 @@ export interface IAppStrings {
warnings: {
existingName: string;
emptyName: string;
unknownTagName: string;
}
};
connections: {
@@ -177,6 +178,10 @@ export interface IAppStrings {
title: string,
description: string,
},
accountKey: {
title: string,
description: string,
},
containerName: {
title: string,
description: string,
@@ -230,6 +235,7 @@ export interface IAppStrings {
nextAsset: string;
saveProject: string;
exportProject: string;
activeLearning: string;
}
videoPlayer: {
nextTaggedFrame: {
@@ -255,6 +261,14 @@ export interface IAppStrings {
apply: string;
lock: string;
},
rename: {
title: string;
confirmation: string;
},
delete: {
title: string;
confirmation: string;
},
}
canvas: {
removeAllRegions: {
@@ -285,23 +299,43 @@ export interface IAppStrings {
tagged: string,
},
},
},
},
vottJson: {
displayName: string,
properties: {
testTrainSplit: {
title: string,
description: string,
},
includeImages: {
title: string,
description: string,
},
},
},
vottJson: {
displayName: string,
},
azureCV: {
displayName: string,
regions: {
eastUs: string,
eastUs2: string,
northCentralUs: string,
southCentralUs: string,
westUs2: string,
westEurope: string,
northEurope: string,
southeastAsia: string,
australiaEast: string,
centralIndia: string,
ukSouth: string,
japanEast: string,
},
properties: {
apiKey: {
title: string,
},
region: {
title: string,
description: string,
},
newOrExisting: {
title: string,
options: {
@@ -340,17 +374,19 @@ export interface IAppStrings {
tfRecords: {
displayName: string,
},
tfPascalVoc: {
pascalVoc: {
displayName: string,
testTrainSplit: {
title: string,
description: string,
},
exportUnassigned: {
title: string,
description: string,
},
},
cntk: {
displayName: string,
},
csv: {
displayName: string,
},
},
messages: {
saveSuccess: string;
@@ -358,6 +394,40 @@ export interface IAppStrings {
};
activeLearning: {
title: string;
form: {
properties: {
modelPathType: {
title: string,
description: string,
options: {
preTrained: string,
customFilePath: string,
customWebUrl: string,
},
},
autoDetect: {
title: string,
description: string,
},
predictTag: {
title: string,
description: string,
},
modelPath: {
title: string,
description: string,
},
modelUrl: {
title: string,
description: string,
},
},
}
messages: {
loadingModel: string;
errorLoadModel: string;
saveSuccess: string;
}
};
profile: {
settings: string;
@@ -374,6 +444,7 @@ export interface IAppStrings {
importError: IErrorMetadata,
pasteRegionTooBigError: IErrorMetadata,
exportFormatNotFound: IErrorMetadata,
activeLearningPredictionError: IErrorMetadata,
};
}
@@ -23,6 +23,7 @@ describe("LocalFileSystem Storage Provider", () => {
a: 1,
b: 2,
c: 3,
d: "한글 中國 にほんご",
};
await localFileSystem.writeText(filePath, JSON.stringify(contents, null, 4));
@@ -71,7 +71,7 @@ export default class LocalFileSystem implements IStorageProvider {
}
public writeText(filePath: string, contents: string): Promise<void> {
const buffer = Buffer.alloc(contents.length, contents);
const buffer = Buffer.from(contents);
return this.writeBinary(filePath, buffer);
}
+42
Ver Arquivo
@@ -1,4 +1,5 @@
import { ExportAssetState } from "../providers/export/exportProvider";
import { IAssetPreviewSettings } from "../react/components/common/assetPreview/assetPreview";
/**
* @name - Application State
@@ -49,6 +50,7 @@ export enum ErrorCode {
ExportFormatNotFound = "exportFormatNotFound",
PasteRegionTooBig = "pasteRegionTooBig",
OverloadedKeyBinding = "overloadedKeyBinding",
ActiveLearningPredictionError = "activeLearningPredictionError",
}
/**
@@ -112,6 +114,7 @@ export interface IProject {
targetConnection: IConnection;
exportFormat: IExportFormat;
videoSettings: IProjectVideoSettings;
activeLearningSettings: IActiveLearningSettings;
autoSave: boolean;
assets?: { [index: string]: IAsset };
lastVisitedAssetId?: string;
@@ -198,6 +201,44 @@ export interface IProjectVideoSettings {
frameExtractionRate: number;
}
/**
* @name - Model Path Type
* @description - Defines the mechanism to load the TF.js model for Active Learning
* @member Coco - Specifies the default/generic pre-trained Coco-SSD model
* @member File - Specifies to load a custom model from filesystem
* @member Url - Specifies to load a custom model from a web server
*/
export enum ModelPathType {
Coco = "coco",
File = "file",
Url = "url",
}
/**
* Properties for additional project settings
* @member activeLearningSettings - Active Learning settings
*/
export interface IAdditionalPageSettings extends IAssetPreviewSettings {
activeLearningSettings: IActiveLearningSettings;
}
/**
* @name - Active Learning Settings for the project
* @description - Defines the active learning settings within a VoTT project
* @member modelPathType - Model loading type ["coco", "file", "url"]
* @member modelPath - Local filesystem path to the TF.js model
* @member modelUrl - Web url to the TF.js model
* @member autoDetect - Flag for automatically call the model while opening a new asset
* @member predictTag - Flag to predict also the tag name other than the rectangle coordinates only
*/
export interface IActiveLearningSettings {
modelPathType: ModelPathType;
modelPath?: string;
modelUrl?: string;
autoDetect: boolean;
predictTag: boolean;
}
/**
* @name - Asset Video Settings
* @description - Defines the settings for video assets
@@ -231,6 +272,7 @@ export interface IAsset {
format?: string;
timestamp?: number;
parent?: IAsset;
predicted?: boolean;
}
/**
@@ -0,0 +1,33 @@
jest.mock("../storage/localFileSystemProxy");
import { LocalFileSystemProxy } from "../storage/localFileSystemProxy";
import { ElectronProxyHandler } from "./electronProxyHandler";
import * as tf from "@tensorflow/tfjs";
// tslint:disable-next-line:no-var-requires
const modelJson = require("../../../cocoSSDModel/model.json");
describe("Load default model from filesystem with TF io.IOHandler", () => {
it("Check file system proxy is correctly called", async () => {
const storageProviderMock = LocalFileSystemProxy as jest.Mock<LocalFileSystemProxy>;
storageProviderMock.mockClear();
storageProviderMock.prototype.readText = jest.fn((fileName) => {
return Promise.resolve(JSON.stringify(modelJson));
});
storageProviderMock.prototype.readBinary = jest.fn((fileName) => {
return Promise.resolve([]);
});
const handler = new ElectronProxyHandler("folder");
try {
const model = await tf.loadGraphModel(handler);
} catch (_) {
// fully loading TF model fails as it has to load also weights
}
expect(LocalFileSystemProxy.prototype.readText).toBeCalledWith("/model.json");
// Coco SSD Lite default embedded model has 5 weights matrix
expect(LocalFileSystemProxy.prototype.readBinary).toBeCalledTimes(5);
});
});
@@ -0,0 +1,76 @@
import * as tfc from "@tensorflow/tfjs-core";
import { LocalFileSystemProxy, ILocalFileSystemProxyOptions } from "../../providers/storage/localFileSystemProxy";
export class ElectronProxyHandler implements tfc.io.IOHandler {
protected readonly provider: LocalFileSystemProxy;
constructor(folderPath: string) {
const options: ILocalFileSystemProxyOptions = { folderPath };
this.provider = new LocalFileSystemProxy(options);
}
public async load(): Promise<tfc.io.ModelArtifacts> {
const modelJSON = JSON.parse(await this.provider.readText("/model.json"));
const modelArtifacts: tfc.io.ModelArtifacts = {
modelTopology: modelJSON.modelTopology,
};
if (modelJSON.weightsManifest != null) {
const [weightSpecs, weightData] =
await this.loadWeights(modelJSON.weightsManifest);
modelArtifacts.weightSpecs = weightSpecs;
modelArtifacts.weightData = weightData;
}
return modelArtifacts;
}
public async loadClasses(): Promise<JSON> {
const json = await this.provider.readText("/classes.json");
return json ? JSON.parse(json) : null;
}
private async loadWeights(weightsManifest: tfc.io.WeightsManifestConfig)
: Promise<[tfc.io.WeightsManifestEntry[], ArrayBuffer]> {
const buffers: Buffer[] = [];
const weightSpecs: tfc.io.WeightsManifestEntry[] = [];
for (const group of weightsManifest) {
for (const shardName of group.paths) {
const buffer = await this.provider.readBinary("/" + shardName);
buffers.push(buffer);
}
weightSpecs.push(...group.weights);
}
return [weightSpecs, this.toArrayBuffer(buffers)];
}
/**
* Convert a Buffer or an Array of Buffers to an ArrayBuffer.
*
* If the input is an Array of Buffers, they will be concatenated in the
* specified order to form the output ArrayBuffer.
*/
private toArrayBuffer(buf: Buffer | Buffer[]): ArrayBuffer {
if (Array.isArray(buf)) {
// An Array of Buffers.
let totalLength = 0;
for (const buffer of buf) {
totalLength += buffer.length;
}
const ab = new ArrayBuffer(totalLength);
const view = new Uint8Array(ab);
let pos = 0;
for (const buffer of buf) {
pos += buffer.copy(view, pos);
}
return ab;
} else {
// A single Buffer. Return a copy of the underlying ArrayBuffer slice.
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
}
}
}
@@ -0,0 +1,123 @@
import * as tf from "@tensorflow/tfjs";
jest.mock("../storage/localFileSystemProxy");
import { LocalFileSystemProxy } from "../storage/localFileSystemProxy";
import { ObjectDetection, DetectedObject } from "./objectDetection";
import { strings } from "../../common/strings";
// tslint:disable-next-line:no-var-requires
const modelJson = require("../../../cocoSSDModel/model.json");
describe("Load an Object Detection model", () => {
it("Load model from file system using proxy", async () => {
const storageProviderMock = LocalFileSystemProxy as jest.Mock<LocalFileSystemProxy>;
storageProviderMock.mockClear();
storageProviderMock.prototype.readText = jest.fn((fileName) => {
return Promise.resolve(JSON.stringify(modelJson));
});
storageProviderMock.prototype.readBinary = jest.fn((fileName) => {
return Promise.resolve([]);
});
const model = new ObjectDetection();
try {
await model.load("path");
} catch (_) {
// fully loading TF model fails has it has to load also weights
}
expect(LocalFileSystemProxy.prototype.readText).toBeCalledWith("/model.json");
// Coco SSD Lite default embedded model has 5 weights matrix
expect(LocalFileSystemProxy.prototype.readBinary).toBeCalledTimes(5);
// Modal not properly loaded as readBinary mock is not really loading the weights
expect(model.loaded).toBeFalsy();
const noDetection = await model.detect(null);
expect(noDetection.length).toEqual(0);
model.dispose();
});
it("Load model from http url", async () => {
window.fetch = jest.fn().mockImplementation((url, o) => {
if (url === "http://url/model.json") {
return Promise.resolve({
ok: true,
json: () => modelJson,
});
} else {
return Promise.resolve({
ok: true,
data: () => [],
});
}
});
const model = new ObjectDetection();
expect(model.load("http://url")).rejects.not.toBeNull();
expect(window.fetch).toBeCalledTimes(1);
// Modal not properly loaded as readBinary mock is not really loading the weights
expect(model.loaded).toBeFalsy();
const noDetection = await model.detect(null);
expect(noDetection.length).toEqual(0);
model.dispose();
});
});
describe("Test Detection on Fake Model", () => {
beforeEach(() => {
spyOn(tf, "loadGraphModel").and.callFake(() => {
const model = {
executeAsync:
(x: tf.Tensor) => [tf.ones([1, 1917, 90]), tf.ones([1, 1917, 1, 4])],
};
return model;
});
});
it("ObjectDetection detect method should generate output", async () => {
const model = new ObjectDetection();
await model.load("path");
const x = tf.zeros([227, 227, 3]) as tf.Tensor3D;
const data = await model.detect(x, 1);
expect(data).toEqual([{bbox: [227, 227, 0, 0], class: strings.tags.warnings.unknownTagName, score: 1}]);
});
});
describe("Test predictImage on Fake Model", () => {
beforeEach(() => {
spyOn(tf, "loadGraphModel").and.callFake(() => {
const model = {
executeAsync:
(x: tf.Tensor) => [tf.ones([1, 1917, 90]), tf.ones([1, 1917, 1, 4])],
};
return model;
});
});
it("predictImage on a fake image", async () => {
const model = new ObjectDetection();
await model.load("path");
const x = tf.zeros([227, 227, 3]) as tf.Tensor3D;
const regions = await model.predictImage(x, false, 1, 1);
expect(regions.length).toEqual(20);
expect(regions[0].boundingBox.left).toEqual(227);
expect(regions[0].boundingBox.top).toEqual(227);
expect(regions[0].boundingBox.width).toEqual(0);
expect(regions[0].boundingBox.height).toEqual(0);
});
});
+253
Ver Arquivo
@@ -0,0 +1,253 @@
import axios from "axios";
import * as shortid from "shortid";
import * as tf from "@tensorflow/tfjs";
import { ElectronProxyHandler } from "./electronProxyHandler";
import { IRegion, RegionType } from "../../models/applicationState";
import { strings } from "../../common/strings";
// tslint:disable-next-line:interface-over-type-literal
export type DetectedObject = {
bbox: [number, number, number, number]; // [x, y, width, height]
class: string;
score: number;
};
/**
* Defines supported data types supported by Tensorflow JS
*/
export type ImageObject = tf.Tensor3D | ImageData | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement;
/**
* Object Dectection loads active learning models and predicts regions
*/
export class ObjectDetection {
private modelLoaded: boolean = false;
get loaded(): boolean {
return this.modelLoaded;
}
private model: tf.GraphModel;
private jsonClasses: JSON;
/**
* Dispose the tensors allocated by the model. You should call this when you
* are done with the model.
*/
public dispose() {
if (this.model) {
this.model.dispose();
}
}
/**
* Load a TensorFlow.js Object Detection model from file: or http URL.
* @param modelFolderPath file: or http URL to the model
*/
public async load(modelFolderPath: string) {
try {
if (modelFolderPath.toLowerCase().startsWith("http://") ||
modelFolderPath.toLowerCase().startsWith("https://")) {
this.model = await tf.loadGraphModel(modelFolderPath + "/model.json");
const response = await axios.get(modelFolderPath + "/classes.json");
this.jsonClasses = JSON.parse(JSON.stringify(response.data));
} else {
const handler = new ElectronProxyHandler(modelFolderPath);
this.model = await tf.loadGraphModel(handler);
this.jsonClasses = await handler.loadClasses();
}
// Warmup the model.
const result = await this.model.executeAsync(tf.zeros([1, 300, 300, 3])) as tf.Tensor[];
result.forEach(async (t) => await t.data());
result.forEach(async (t) => t.dispose());
this.modelLoaded = true;
} catch (err) {
this.modelLoaded = false;
throw err;
}
}
/**
* Predict Regions from an HTMLImageElement returning list of IRegion.
* @param image ImageObject to be used for prediction
* @param predictTag Flag indicates if predict only region bounding box of tag too.
* @param xRatio Width compression ratio between the HTMLImageElement and the original image.
* @param yRatio Height compression ratio between the HTMLImageElement and the original image.
*/
public async predictImage(image: ImageObject, predictTag: boolean, xRatio: number, yRatio: number)
: Promise<IRegion[]> {
const regions: IRegion[] = [];
const predictions = await this.detect(image);
predictions.forEach((prediction) => {
const left = Math.max(0, prediction.bbox[0] * xRatio);
const top = Math.max(0, prediction.bbox[1] * yRatio);
const width = Math.max(0, prediction.bbox[2] * xRatio);
const height = Math.max(0, prediction.bbox[3] * yRatio);
regions.push({
id: shortid.generate(),
type: RegionType.Rectangle,
tags: predictTag ? [prediction.class] : [],
boundingBox: {
left,
top,
width,
height,
},
points: [{
x: left,
y: top,
},
{
x: left + width,
y: top,
},
{
x: left + width,
y: top + height,
},
{
x: left,
y: top + height,
}],
});
});
return regions;
}
/**
* Detect objects for an image returning a list of bounding boxes with
* associated class and score.
*
* @param img The image to detect objects from. Can be a tensor or a DOM
* element image, video, or canvas.
* @param maxNumBoxes The maximum number of bounding boxes of detected
* objects. There can be multiple objects of the same class, but at different
* locations. Defaults to 20.
*
*/
public async detect(img: ImageObject, maxNumBoxes: number = 20): Promise<DetectedObject[]> {
if (this.model) {
return this.infer(img, maxNumBoxes);
}
return [];
}
/**
* Infers through the model.
*
* @param img The image to classify. Can be a tensor or a DOM element image,
* video, or canvas.
* @param maxNumBoxes The maximum number of bounding boxes of detected
* objects. There can be multiple objects of the same class, but at different
* locations. Defaults to 20.
*/
private async infer(img: ImageObject, maxNumBoxes: number = 20): Promise<DetectedObject[]> {
const batched = tf.tidy(() => {
if (!(img instanceof tf.Tensor)) {
img = tf.browser.fromPixels(img);
}
// Reshape to a single-element batch so we can pass it to executeAsync.
return img.expandDims(0);
});
const height = batched.shape[1];
const width = batched.shape[2];
// model returns two tensors:
// 1. box classification score with shape of [1, 1917, 90]
// 2. box location with shape of [1, 1917, 1, 4]
// where 1917 is the number of box detectors, 90 is the number of classes.
// and 4 is the four coordinates of the box.
const result = await this.model.executeAsync(batched) as tf.Tensor[];
const scores = result[0].dataSync() as Float32Array;
const boxes = result[1].dataSync() as Float32Array;
// clean the webgl tensors
batched.dispose();
tf.dispose(result);
const [maxScores, classes] = this.calculateMaxScores(scores, result[0].shape[1], result[0].shape[2]);
const prevBackend = tf.getBackend();
// run post process in cpu
tf.setBackend("cpu");
const indexTensor = tf.tidy(() => {
const boxes2 = tf.tensor2d(boxes, [result[1].shape[1], result[1].shape[3]]);
return tf.image.nonMaxSuppression(boxes2, maxScores, maxNumBoxes, 0.5, 0.5);
});
const indexes = indexTensor.dataSync() as Float32Array;
indexTensor.dispose();
// restore previous backend
tf.setBackend(prevBackend);
return this.buildDetectedObjects(width, height, boxes, maxScores, indexes, classes);
}
private buildDetectedObjects(
width: number, height: number, boxes: Float32Array, scores: number[],
indexes: Float32Array, classes: number[]): DetectedObject[] {
const count = indexes.length;
const objects: DetectedObject[] = [];
for (let i = 0; i < count; i++) {
const bbox = [];
for (let j = 0; j < 4; j++) {
bbox[j] = boxes[indexes[i] * 4 + j];
}
const minY = bbox[0] * height;
const minX = bbox[1] * width;
const maxY = bbox[2] * height;
const maxX = bbox[3] * width;
bbox[0] = minX;
bbox[1] = minY;
bbox[2] = maxX - minX;
bbox[3] = maxY - minY;
objects.push({
bbox: bbox as [number, number, number, number],
class: this.getClass(i, indexes, classes),
score: scores[indexes[i]],
});
}
return objects;
}
private getClass(index: number, indexes: Float32Array, classes: number[]): string {
if (this.jsonClasses && index < indexes.length && indexes[index] < classes.length) {
const classId = classes[indexes[index]] - 1;
const classObject = this.jsonClasses[classId];
return classObject ? classObject.displayName : strings.tags.warnings.unknownTagName;
}
return "";
}
private calculateMaxScores(
scores: Float32Array, numBoxes: number,
numClasses: number): [number[], number[]] {
const maxes = [];
const classes = [];
for (let i = 0; i < numBoxes; i++) {
let max = Number.MIN_VALUE;
let index = -1;
for (let j = 0; j < numClasses; j++) {
if (scores[i * numClasses + j] > max) {
max = scores[i * numClasses + j];
index = j;
}
}
maxes[i] = max;
classes[i] = index;
}
return [maxes, classes];
}
}
+43 -6
Ver Arquivo
@@ -3,7 +3,8 @@
"title": "${strings.export.providers.azureCV.displayName}",
"required": [
"assetState",
"apiKey"
"apiKey",
"region"
],
"properties": {
"assetState": {
@@ -22,6 +23,40 @@
"${strings.export.providers.common.properties.assetState.options.tagged}"
]
},
"region": {
"type": "string",
"title": "${strings.export.providers.azureCV.properties.region.title}",
"description": "${strings.export.providers.azureCV.properties.region.description}",
"default": "southcentralus",
"enum": [
"australiaeast",
"centralindia",
"eastus",
"eastus2",
"japaneast",
"northcentralus",
"northeurope",
"southcentralus",
"southeastasia",
"uksouth",
"westus2",
"westeurope"
],
"enumNames": [
"${strings.export.providers.azureCV.regions.australiaEast}",
"${strings.export.providers.azureCV.regions.centralIndia}",
"${strings.export.providers.azureCV.regions.eastUs}",
"${strings.export.providers.azureCV.regions.eastUs2}",
"${strings.export.providers.azureCV.regions.japanEast}",
"${strings.export.providers.azureCV.regions.northCentralUs}",
"${strings.export.providers.azureCV.regions.northEurope}",
"${strings.export.providers.azureCV.regions.southCentralUs}",
"${strings.export.providers.azureCV.regions.southeastAsia}",
"${strings.export.providers.azureCV.regions.ukSouth}",
"${strings.export.providers.azureCV.regions.westUs2}",
"${strings.export.providers.azureCV.regions.westEurope}"
]
},
"apiKey": {
"type": "string",
"title": "${strings.export.providers.azureCV.properties.apiKey.title}"
@@ -61,19 +96,20 @@
"projectType": {
"type": "string",
"title": "${strings.export.providers.azureCV.properties.projectType.title}",
"default": "Classification",
"enum": [
"Classification",
"Object Detection"
"ObjectDetection"
],
"enumNames": [
"${strings.export.providers.azureCV.properties.projectType.options.classification}",
"${strings.export.providers.azureCV.properties.projectType.options.objectDetection}"
],
"default": "Classification"
]
},
"classificationType": {
"type": "string",
"title": "${strings.export.providers.azureCV.properties.classificationType.title}",
"default": "Multilabel",
"enum": [
"Multilabel",
"Multiclass"
@@ -81,8 +117,7 @@
"enumNames": [
"${strings.export.providers.azureCV.properties.classificationType.options.multiLabel}",
"${strings.export.providers.azureCV.properties.classificationType.options.multiClass}"
],
"default": "Multilabel"
]
},
"domainId": {
"type": "string",
@@ -91,6 +126,8 @@
},
"required": [
"name",
"projectType",
"classificationType",
"domainId"
]
},
+19 -2
Ver Arquivo
@@ -1,12 +1,15 @@
import shortid from "shortid";
import _ from "lodash";
import { AzureCustomVisionProvider, IAzureCustomVisionExportOptions, NewOrExisting } from "./azureCustomVision";
import {
AzureCustomVisionProvider, IAzureCustomVisionExportOptions,
NewOrExisting, AzureRegion,
} from "./azureCustomVision";
import registerProviders from "../../registerProviders";
import { ExportProviderFactory } from "./exportProviderFactory";
import MockFactory from "../../common/mockFactory";
import {
IProject, AssetState, IAsset, IAssetMetadata,
RegionType, IRegion, IExportProviderOptions, AssetType,
RegionType, IRegion, IExportProviderOptions,
} from "../../models/applicationState";
import { ExportAssetState } from "./exportProvider";
jest.mock("./azureCustomVision/azureCustomVisionService");
@@ -29,6 +32,7 @@ describe("Azure Custom Vision Export Provider", () => {
let testProject: IProject = null;
const defaultOptions: IAzureCustomVisionExportOptions = {
apiKey: expect.any(String),
region: AzureRegion.SouthCentralUS,
assetState: ExportAssetState.All,
newOrExisting: NewOrExisting.New,
projectId: expect.any(String),
@@ -64,6 +68,7 @@ describe("Azure Custom Vision Export Provider", () => {
assetState: ExportAssetState.All,
projectId: "azure-custom-vision-project-1",
apiKey: "ABC123",
region: AzureRegion.SouthCentralUS,
},
},
};
@@ -81,6 +86,18 @@ describe("Azure Custom Vision Export Provider", () => {
expect(provider).toBeInstanceOf(AzureCustomVisionProvider);
});
it("Constructs custom vision service with correct options", () => {
const customVisionMock = AzureCustomVisionService as jest.Mocked<typeof AzureCustomVisionService>;
const providerOptions = testProject.exportFormat.providerOptions as IAzureCustomVisionExportOptions;
providerOptions.region = AzureRegion.WestEurope;
createProvider(testProject);
expect(customVisionMock).toBeCalledWith({
apiKey: providerOptions.apiKey,
baseUrl: `https://${providerOptions.region}.api.cognitive.microsoft.com/customvision/v2.2/Training`,
});
});
it("Calling save with New project creates Azure Custom Vision project", async () => {
const customVisionMock = AzureCustomVisionService as jest.Mocked<typeof AzureCustomVisionService>;
customVisionMock.prototype.create = jest.fn((project) => {
+25 -1
Ver Arquivo
@@ -17,6 +17,7 @@ import HtmlFileReader from "../../common/htmlFileReader";
export interface IAzureCustomVisionExportOptions extends IExportProviderOptions {
assetState: ExportAssetState;
newOrExisting: NewOrExisting;
region: AzureRegion;
apiKey: string;
projectId?: string;
name?: string;
@@ -38,6 +39,24 @@ export enum NewOrExisting {
Existing = "existing",
}
/**
* Azure regions
*/
export enum AzureRegion {
EastUS = "eastus",
EastUS2 = "eastus2",
NorthCentralUS = "northcentralus",
SouthCentralUS = "southcentralus",
WestUS2 = "westus2",
WestEurope = "westeurope",
NorthEurope = "northeurope",
SoutheastAsia = "southeastasia",
AustraliaEast = "australiaeast",
CentralIndia = "centralindia",
UKSouth = "uksouth",
JapanEast = "japaneast",
}
/**
* @name - Azure Custom Vision Provider
* @description - Exports a VoTT project into an Azure custom vision project
@@ -49,9 +68,13 @@ export class AzureCustomVisionProvider extends ExportProvider<IAzureCustomVision
super(project, options);
Guard.null(options);
if (!options.region) {
options.region = AzureRegion.SouthCentralUS;
}
const cusomVisionServiceOptions: IAzureCustomVisionServiceOptions = {
apiKey: options.apiKey,
baseUrl: "https://southcentralus.api.cognitive.microsoft.com/customvision/v2.2/Training",
baseUrl: `https://${options.region}.api.cognitive.microsoft.com/customvision/v2.2/Training`,
};
this.customVisionService = new AzureCustomVisionService(cusomVisionServiceOptions);
}
@@ -111,6 +134,7 @@ export class AzureCustomVisionProvider extends ExportProvider<IAzureCustomVision
return {
assetState: customVisionOptions.assetState,
region: customVisionOptions.region,
apiKey: customVisionOptions.apiKey,
projectId: customVisionProject.id,
newOrExisting: NewOrExisting.Existing,
+8 -3
Ver Arquivo
@@ -9,7 +9,7 @@
"ui:widget": "externalPicker",
"ui:options": {
"method": "GET",
"url": "https://southcentralus.api.cognitive.microsoft.com/customvision/v2.2/Training/projects",
"url": "https://${props.formContext.providerOptions.region}.api.cognitive.microsoft.com/customvision/v2.2/Training/projects",
"authHeaderName": "Training-key",
"authHeaderValue": "${props.formContext.providerOptions.apiKey}",
"keySelector": "${item.id}",
@@ -20,11 +20,16 @@
"ui:widget": "externalPicker",
"ui:options": {
"method": "GET",
"url": "https://southcentralus.api.cognitive.microsoft.com/customvision/v2.2/Training/domains",
"url": "https://${props.formContext.providerOptions.region}.api.cognitive.microsoft.com/customvision/v2.2/Training/domains",
"authHeaderName": "Training-key",
"authHeaderValue": "${props.formContext.providerOptions.apiKey}",
"keySelector": "${item.id}",
"valueSelector": "${item.name}"
"valueSelector": "${item.name}",
"filter": {
"left": "${item.type}",
"operator": "eq",
"right": "${props.formContext.providerOptions.projectType}"
}
}
}
}
+28
Ver Arquivo
@@ -0,0 +1,28 @@
{
"type": "object",
"title": "${strings.export.providers.cntk.displayName}",
"properties": {
"assetState": {
"type": "string",
"title": "${strings.export.providers.common.properties.assetState.title}",
"description": "${strings.export.providers.common.properties.assetState.description}",
"enum": [
"all",
"visited",
"tagged"
],
"default": "visited",
"enumNames": [
"${strings.export.providers.common.properties.assetState.options.all}",
"${strings.export.providers.common.properties.assetState.options.visited}",
"${strings.export.providers.common.properties.assetState.options.tagged}"
]
},
"testTrainSplit": {
"title": "${strings.export.providers.common.properties.testTrainSplit.title}",
"description": "${strings.export.providers.common.properties.testTrainSplit.description}",
"type": "number",
"default": 80
}
}
}
+167
Ver Arquivo
@@ -0,0 +1,167 @@
import _ from "lodash";
import os from "os";
import { CntkExportProvider, ICntkExportProviderOptions } from "./cntk";
import { IProject, AssetState, IAssetMetadata } from "../../models/applicationState";
import { AssetProviderFactory } from "../storage/assetProviderFactory";
import { ExportAssetState } from "./exportProvider";
import MockFactory from "../../common/mockFactory";
import registerMixins from "../../registerMixins";
import registerProviders from "../../registerProviders";
import { ExportProviderFactory } from "./exportProviderFactory";
jest.mock("../../services/assetService");
import { AssetService } from "../../services/assetService";
jest.mock("../storage/localFileSystemProxy");
import { LocalFileSystemProxy } from "../storage/localFileSystemProxy";
import HtmlFileReader from "../../common/htmlFileReader";
import { appInfo } from "../../common/appInfo";
describe("CNTK Export Provider", () => {
const testAssets = MockFactory.createTestAssets(10, 1);
let testProject: IProject = null;
const defaultOptions: ICntkExportProviderOptions = {
assetState: ExportAssetState.Tagged,
testTrainSplit: 80,
};
function createProvider(project: IProject): CntkExportProvider {
return new CntkExportProvider(
project,
project.exportFormat.providerOptions as ICntkExportProviderOptions,
);
}
beforeAll(() => {
registerMixins();
registerProviders();
HtmlFileReader.getAssetBlob = jest.fn(() => {
return Promise.resolve(new Blob(["Some binary data"]));
});
});
beforeEach(() => {
jest.resetAllMocks();
testAssets.forEach((asset) => {
asset.state = AssetState.Tagged;
});
testProject = {
...MockFactory.createTestProject("TestProject"),
assets: _.keyBy(testAssets, (a) => a.id),
exportFormat: {
providerType: "cntk",
providerOptions: defaultOptions,
},
};
AssetProviderFactory.create = jest.fn(() => {
return {
getAssets: jest.fn(() => Promise.resolve(testAssets)),
};
});
const assetServiceMock = AssetService as jest.Mocked<typeof AssetService>;
assetServiceMock.prototype.getAssetMetadata = jest.fn((asset) => {
const assetMetadata = {
asset: { ...asset },
regions: [
MockFactory.createTestRegion("region-1", ["tag1"]),
MockFactory.createTestRegion("region-2", ["tag1"]),
],
version: appInfo.version,
};
return Promise.resolve(assetMetadata);
});
});
it("Is defined", () => {
expect(CntkExportProvider).toBeDefined();
});
it("Can be instantiated through the factory", () => {
const options: ICntkExportProviderOptions = {
assetState: ExportAssetState.All,
testTrainSplit: 80,
};
const exportProvider = ExportProviderFactory.create("cntk", testProject, options);
expect(exportProvider).not.toBeNull();
expect(exportProvider).toBeInstanceOf(CntkExportProvider);
});
it("Creates correct folder structure", async () => {
const provider = createProvider(testProject);
await provider.export();
const storageProviderMock = LocalFileSystemProxy as any;
const createContainerCalls = storageProviderMock.mock.instances[0].createContainer.mock.calls;
const createContainerArgs = createContainerCalls.map((args) => args[0]);
const expectedFolderPath = "Project-TestProject-CNTK-export";
expect(createContainerArgs).toContain(expectedFolderPath);
expect(createContainerArgs).toContain(`${expectedFolderPath}/positive`);
expect(createContainerArgs).toContain(`${expectedFolderPath}/negative`);
expect(createContainerArgs).toContain(`${expectedFolderPath}/testImages`);
});
it("Writes export files to storage provider", async () => {
const provider = createProvider(testProject);
const getAssetsSpy = jest.spyOn(provider, "getAssetsForExport");
await provider.export();
const assetsToExport = await getAssetsSpy.mock.results[0].value;
const testSplit = (100 - (defaultOptions.testTrainSplit || 80)) / 100;
const testCount = Math.ceil(assetsToExport.length * testSplit);
const testArray = assetsToExport.slice(0, testCount);
const trainArray = assetsToExport.slice(testCount, assetsToExport.length);
const storageProviderMock = LocalFileSystemProxy as any;
const writeBinaryCalls = storageProviderMock.mock.instances[0].writeBinary.mock.calls;
const writeTextFileCalls = storageProviderMock.mock.instances[0].writeText.mock.calls;
expect(writeBinaryCalls).toHaveLength(testAssets.length);
expect(writeTextFileCalls).toHaveLength(testAssets.length * 2);
testArray.forEach((assetMetadata) => {
const testFolderPath = "Project-TestProject-CNTK-export/testImages";
assertExportedAsset(testFolderPath, assetMetadata);
});
trainArray.forEach((assetMetadata) => {
const trainFolderPath = "Project-TestProject-CNTK-export/positive";
assertExportedAsset(trainFolderPath, assetMetadata);
});
});
function assertExportedAsset(folderPath: string, assetMetadata: IAssetMetadata) {
const storageProviderMock = LocalFileSystemProxy as any;
const writeBinaryCalls = storageProviderMock.mock.instances[0].writeBinary.mock.calls;
const writeBinaryFilenameArgs = writeBinaryCalls.map((args) => args[0]);
const writeTextFileCalls = storageProviderMock.mock.instances[0].writeText.mock.calls;
const writeTextFilenameArgs = writeTextFileCalls.map((args) => args[0]);
expect(writeBinaryFilenameArgs).toContain(`${folderPath}/${assetMetadata.asset.name}`);
expect(writeTextFilenameArgs).toContain(`${folderPath}/${assetMetadata.asset.name}.bboxes.labels.tsv`);
expect(writeTextFilenameArgs).toContain(`${folderPath}/${assetMetadata.asset.name}.bboxes.tsv`);
const writeLabelsCall = writeTextFileCalls
.find((args: string[]) => args[0].indexOf(`${assetMetadata.asset.name}.bboxes.labels.tsv`) >= 0);
const writeBoxesCall = writeTextFileCalls
.find((args: string[]) => args[0].indexOf(`${assetMetadata.asset.name}.bboxes.tsv`) >= 0);
const expectedLabelData = `${assetMetadata.regions[0].tags[0]}${os.EOL}${assetMetadata.regions[1].tags[0]}`;
expect(writeLabelsCall[1]).toEqual(expectedLabelData);
const expectedBoxData = [];
// tslint:disable-next-line:max-line-length
expectedBoxData.push(`${assetMetadata.regions[0].boundingBox.left}\t${assetMetadata.regions[0].boundingBox.left + assetMetadata.regions[0].boundingBox.width}\t${assetMetadata.regions[0].boundingBox.top}\t${assetMetadata.regions[0].boundingBox.top + assetMetadata.regions[0].boundingBox.height}`);
// tslint:disable-next-line:max-line-length
expectedBoxData.push(`${assetMetadata.regions[1].boundingBox.left}\t${assetMetadata.regions[1].boundingBox.left + assetMetadata.regions[1].boundingBox.width}\t${assetMetadata.regions[1].boundingBox.top}\t${assetMetadata.regions[1].boundingBox.top + assetMetadata.regions[1].boundingBox.height}`);
expect(writeBoxesCall[1]).toEqual(expectedBoxData.join(os.EOL));
}
});
+104
Ver Arquivo
@@ -0,0 +1,104 @@
import os from "os";
import { ExportProvider, IExportResults } from "./exportProvider";
import { IAssetMetadata, IExportProviderOptions, IProject } from "../../models/applicationState";
import HtmlFileReader from "../../common/htmlFileReader";
import Guard from "../../common/guard";
enum ExportSplit {
Test,
Train,
}
/**
* Export options for CNTK export provider
*/
export interface ICntkExportProviderOptions extends IExportProviderOptions {
/** The test / train split ratio for exporting data */
testTrainSplit?: number;
}
/**
* CNTK Export provider
*/
export class CntkExportProvider extends ExportProvider<ICntkExportProviderOptions> {
private exportFolderName: string;
constructor(project: IProject, options: ICntkExportProviderOptions) {
super(project, options);
Guard.null(options);
this.exportFolderName = `${this.project.name.replace(/\s/g, "-")}-CNTK-export`;
}
public async export(): Promise<IExportResults> {
await this.createFolderStructure();
const assetsToExport = await this.getAssetsForExport();
const testSplit = (100 - (this.options.testTrainSplit || 80)) / 100;
const testCount = Math.ceil(assetsToExport.length * testSplit);
const testArray = assetsToExport.slice(0, testCount);
const results = await assetsToExport.mapAsync(async (assetMetadata) => {
try {
const exportSplit = testArray.find((am) => am.asset.id === assetMetadata.asset.id)
? ExportSplit.Test
: ExportSplit.Train;
await this.exportAssetFrame(assetMetadata, exportSplit);
return {
asset: assetMetadata,
success: true,
};
} catch (e) {
return {
asset: assetMetadata,
success: false,
error: e,
};
}
});
return {
completed: results.filter((r) => r.success),
errors: results.filter((r) => !r.success),
count: results.length,
};
}
private async exportAssetFrame(assetMetadata: IAssetMetadata, exportSplit: ExportSplit) {
const labelData = [];
const boundingBoxData = [];
assetMetadata.regions.forEach((region) => {
region.tags.forEach((tagName) => {
labelData.push(tagName);
// tslint:disable-next-line:max-line-length
boundingBoxData.push(`${region.boundingBox.left}\t${region.boundingBox.left + region.boundingBox.width}\t${region.boundingBox.top}\t${region.boundingBox.top + region.boundingBox.height}`);
});
});
const buffer = await HtmlFileReader.getAssetArray(assetMetadata.asset);
const folderName = exportSplit === ExportSplit.Train ? "positive" : "testImages";
const labelsPath = `${this.exportFolderName}/${folderName}/${assetMetadata.asset.name}.bboxes.labels.tsv`;
const boundingBoxPath = `${this.exportFolderName}/${folderName}/${assetMetadata.asset.name}.bboxes.tsv`;
const binaryPath = `${this.exportFolderName}/${folderName}/${assetMetadata.asset.name}`;
await Promise.all([
this.storageProvider.writeText(labelsPath, labelData.join(os.EOL)),
this.storageProvider.writeText(boundingBoxPath, boundingBoxData.join(os.EOL)),
this.storageProvider.writeBinary(binaryPath, Buffer.from(buffer)),
]);
}
private async createFolderStructure(): Promise<void> {
const positiveFolder = `${this.exportFolderName}/positive`;
const negativeFolder = `${this.exportFolderName}/negative`;
const testImagesFolder = `${this.exportFolderName}/testImages`;
await this.storageProvider.createContainer(this.exportFolderName);
await [positiveFolder, negativeFolder, testImagesFolder]
.forEachAsync(async (folderPath) => {
await this.storageProvider.createContainer(folderPath);
});
}
}
+5
Ver Arquivo
@@ -0,0 +1,5 @@
{
"testTrainSplit": {
"ui:widget": "slider"
}
}
+28
Ver Arquivo
@@ -0,0 +1,28 @@
{
"type": "object",
"title": "${strings.export.providers.csv.displayName}",
"properties": {
"assetState": {
"type": "string",
"title": "${strings.export.providers.common.properties.assetState.title}",
"description": "${strings.export.providers.common.properties.assetState.description}",
"enum": [
"all",
"visited",
"tagged"
],
"default": "visited",
"enumNames": [
"${strings.export.providers.common.properties.assetState.options.all}",
"${strings.export.providers.common.properties.assetState.options.visited}",
"${strings.export.providers.common.properties.assetState.options.tagged}"
]
},
"includeImages": {
"type": "boolean",
"default": true,
"title": "${strings.export.providers.common.properties.includeImages.title}",
"description": "${strings.export.providers.common.properties.includeImages.description}"
}
}
}
+159
Ver Arquivo
@@ -0,0 +1,159 @@
import _ from "lodash";
import { CsvExportProvider, ICsvExportProviderOptions } from "./csv";
import registerProviders from "../../registerProviders";
import { ExportAssetState } from "./exportProvider";
import { ExportProviderFactory } from "./exportProviderFactory";
import {
IProject, IAssetMetadata, AssetState, IExportProviderOptions,
RegionType,
} from "../../models/applicationState";
import MockFactory from "../../common/mockFactory";
jest.mock("../../services/assetService");
import { AssetService } from "../../services/assetService";
jest.mock("../storage/localFileSystemProxy");
import { LocalFileSystemProxy } from "../storage/localFileSystemProxy";
import registerMixins from "../../registerMixins";
import HtmlFileReader from "../../common/htmlFileReader";
import { appInfo } from "../../common/appInfo";
import { AssetProviderFactory } from "../storage/assetProviderFactory";
import os from "os";
registerMixins();
describe("CSV Format Export Provider", () => {
const testAssets = MockFactory.createTestAssets(10, 1);
const testProject: IProject = {
...MockFactory.createTestProject(),
assets: {
"asset-1": MockFactory.createTestAsset("1", AssetState.Tagged),
"asset-2": MockFactory.createTestAsset("2", AssetState.Tagged),
"asset-3": MockFactory.createTestAsset("3", AssetState.Visited),
"asset-4": MockFactory.createTestAsset("4", AssetState.NotVisited),
},
exportFormat: {
providerType: "csv",
providerOptions: {
assetState: ExportAssetState.All,
},
},
};
const expectedFileName = "vott-csv-export/" + testProject.name.replace(" ", "-") + "-export.csv";
beforeAll(() => {
HtmlFileReader.getAssetBlob = jest.fn(() => {
return Promise.resolve(new Blob(["Some binary data"]));
});
AssetProviderFactory.create = jest.fn(() => {
return {
getAssets: jest.fn(() => Promise.resolve(testAssets)),
};
});
});
beforeEach(() => {
registerProviders();
});
it("Is defined", () => {
expect(CsvExportProvider).toBeDefined();
});
it("Can be instantiated through the factory", () => {
const options: IExportProviderOptions = {
assetState: ExportAssetState.All,
};
const exportProvider = ExportProviderFactory.create("csv", testProject, options);
expect(exportProvider).not.toBeNull();
expect(exportProvider).toBeInstanceOf(CsvExportProvider);
});
describe("Export variations", () => {
beforeEach(() => {
const assetServiceMock = AssetService as jest.Mocked<typeof AssetService>;
assetServiceMock.prototype.getAssetMetadata = jest.fn((asset) => {
const assetMetadata: IAssetMetadata = {
asset,
regions: [
{
id: "1",
type: RegionType.Rectangle,
tags: ["a", "b"],
boundingBox: {
left: 1,
top: 2,
width: 3,
height: 4,
},
},
],
version: appInfo.version,
};
return Promise.resolve(assetMetadata);
});
const storageProviderMock = LocalFileSystemProxy as jest.Mock<LocalFileSystemProxy>;
storageProviderMock.prototype.writeText.mockClear();
storageProviderMock.prototype.writeBinary.mockClear();
storageProviderMock.mockClear();
});
it("Exports all assets", async () => {
const options: ICsvExportProviderOptions = {
assetState: ExportAssetState.All,
includeImages: false,
};
const exportProvider = new CsvExportProvider(testProject, options);
await exportProvider.export();
const storageProviderMock = LocalFileSystemProxy as any;
const exportCsv = storageProviderMock.mock.instances[0].writeText.mock.calls[0][1];
const records = exportCsv.split(os.EOL);
// 10 assets - Each with 1 region and 2 tags
expect(records.length).toEqual(testAssets.length * 2 + 1);
expect(LocalFileSystemProxy.prototype.writeText)
.toBeCalledWith(expectedFileName, expect.any(String));
});
it("Exports only visited assets (includes tagged)", async () => {
const options: ICsvExportProviderOptions = {
assetState: ExportAssetState.Visited,
includeImages: false,
};
const exportProvider = new CsvExportProvider(testProject, options);
await exportProvider.export();
const storageProviderMock = LocalFileSystemProxy as any;
const exportCsv = storageProviderMock.mock.instances[0].writeText.mock.calls[0][1];
const records = exportCsv.split(os.EOL);
// 2 tagged / 1 visited assets - Each with 1 region and 2 tags
expect(records.length).toEqual(7);
});
it("Exports only tagged assets", async () => {
const options: ICsvExportProviderOptions = {
assetState: ExportAssetState.Tagged,
includeImages: false,
};
const exportProvider = new CsvExportProvider(testProject, options);
await exportProvider.export();
const storageProviderMock = LocalFileSystemProxy as any;
const exportCsv = storageProviderMock.mock.instances[0].writeText.mock.calls[0][1];
const records = exportCsv.split(os.EOL);
// 2 tagged - Each with 1 region and 2 tags
expect(records.length).toEqual(5);
});
});
});
+73
Ver Arquivo
@@ -0,0 +1,73 @@
import _ from "lodash";
import { ExportProvider } from "./exportProvider";
import { IProject, IExportProviderOptions } from "../../models/applicationState";
import Guard from "../../common/guard";
import HtmlFileReader from "../../common/htmlFileReader";
import json2csv, { Parser } from "json2csv";
/**
* Options for CSV Export Provider
*/
export interface ICsvExportProviderOptions extends IExportProviderOptions {
/** Whether or not to include binary assets in target connection */
includeImages: boolean;
}
/**
* @name - CSV Format Export Provider
* @description - Exports a project into a single CSV file that include all configured assets
*/
export class CsvExportProvider extends ExportProvider<ICsvExportProviderOptions> {
constructor(project: IProject, options: ICsvExportProviderOptions) {
super(project, options);
Guard.null(options);
}
/**
* Export project to CSV
*/
public async export(): Promise<void> {
const results = await this.getAssetsForExport();
const dataItems = [];
await results.forEachAsync(async (assetMetadata) => {
if (this.options.includeImages) {
// Write Image
const arrayBuffer = await HtmlFileReader.getAssetArray(assetMetadata.asset);
const assetFilePath = `vott-csv-export/${assetMetadata.asset.name}`;
await this.storageProvider.writeBinary(assetFilePath, Buffer.from(arrayBuffer));
}
// Push CSV Records
// The CSV file itself must have the following format::
// image,xmin,ymin,xmax,ymax,label
// image_1.jpg,26,594,86,617,cat
// image_1.jpg,599,528,612,541,car
// image_2.jpg,393,477,430,552,dog
assetMetadata.regions.forEach((region) => {
region.tags.forEach((tag) => {
const dataItem = {
image: assetMetadata.asset.name,
xmin: region.boundingBox.left,
ymin: region.boundingBox.top,
xmax: region.boundingBox.left + region.boundingBox.width,
ymax: region.boundingBox.top + region.boundingBox.height,
label: tag,
};
dataItems.push(dataItem);
});
});
});
// Configure CSV options
const csvOptions: json2csv.Options<{}> = {
fields: ["image", "xmin", "ymin", "xmax", "ymax", "label"],
};
const csvParser = new Parser(csvOptions);
const csvData = csvParser.parse(dataItems);
// Save CSV
const fileName = `vott-csv-export/${this.project.name.replace(/\s/g, "-")}-export.csv`;
await this.storageProvider.writeText(fileName, csvData);
}
}
+5
Ver Arquivo
@@ -0,0 +1,5 @@
{
"includeImages": {
"ui:widget": "checkbox"
}
}
@@ -1,6 +1,6 @@
{
"type": "object",
"title": "${strings.export.providers.tfPascalVoc.displayName}",
"title": "${strings.export.providers.pascalVoc.displayName}",
"properties": {
"assetState": {
"type": "string",
@@ -19,14 +19,14 @@
]
},
"testTrainSplit": {
"title": "${strings.export.providers.tfPascalVoc.testTrainSplit.title}",
"description": "${strings.export.providers.tfPascalVoc.testTrainSplit.description}",
"title": "${strings.export.providers.common.properties.testTrainSplit.title}",
"description": "${strings.export.providers.common.properties.testTrainSplit.description}",
"type": "number",
"default": 80
},
"exportUnassigned": {
"title": "${strings.export.providers.tfPascalVoc.exportUnassigned.title}",
"description": "${strings.export.providers.tfPascalVoc.exportUnassigned.description}",
"title": "${strings.export.providers.pascalVoc.exportUnassigned.title}",
"description": "${strings.export.providers.pascalVoc.exportUnassigned.description}",
"type": "boolean",
"default": true
}
@@ -1,5 +1,5 @@
import _ from "lodash";
import { TFPascalVOCExportProvider, ITFPascalVOCExportProviderOptions } from "./tensorFlowPascalVOC";
import { PascalVOCExportProvider, IPascalVOCExportProviderOptions } from "./pascalVOC";
import { ExportAssetState } from "./exportProvider";
import registerProviders from "../../registerProviders";
import { ExportProviderFactory } from "./exportProviderFactory";
@@ -21,7 +21,7 @@ import { AssetProviderFactory } from "../storage/assetProviderFactory";
registerMixins();
describe("TFPascalVOC Json Export Provider", () => {
describe("PascalVOC Json Export Provider", () => {
const testAssets = MockFactory.createTestAssets(10, 1);
const baseTestProject = MockFactory.createTestProject("Test Project");
baseTestProject.assets = {
@@ -51,18 +51,18 @@ describe("TFPascalVOC Json Export Provider", () => {
});
it("Is defined", () => {
expect(TFPascalVOCExportProvider).toBeDefined();
expect(PascalVOCExportProvider).toBeDefined();
});
it("Can be instantiated through the factory", () => {
const options: ITFPascalVOCExportProviderOptions = {
const options: IPascalVOCExportProviderOptions = {
assetState: ExportAssetState.All,
exportUnassigned: true,
testTrainSplit: 80,
};
const exportProvider = ExportProviderFactory.create("tensorFlowPascalVOC", baseTestProject, options);
const exportProvider = ExportProviderFactory.create("pascalVOC", baseTestProject, options);
expect(exportProvider).not.toBeNull();
expect(exportProvider).toBeInstanceOf(TFPascalVOCExportProvider);
expect(exportProvider).toBeInstanceOf(PascalVOCExportProvider);
});
describe("Export variations", () => {
@@ -87,7 +87,7 @@ describe("TFPascalVOC Json Export Provider", () => {
});
it("Exports all assets", async () => {
const options: ITFPascalVOCExportProviderOptions = {
const options: IPascalVOCExportProviderOptions = {
assetState: ExportAssetState.All,
exportUnassigned: true,
testTrainSplit: 80,
@@ -96,7 +96,7 @@ describe("TFPascalVOC Json Export Provider", () => {
const testProject = { ...baseTestProject };
testProject.tags = MockFactory.createTestTags(3);
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
const exportProvider = new PascalVOCExportProvider(testProject, options);
await exportProvider.export();
const storageProviderMock = LocalFileSystemProxy as any;
@@ -145,7 +145,7 @@ describe("TFPascalVOC Json Export Provider", () => {
});
it("Exports only visited assets (includes tagged)", async () => {
const options: ITFPascalVOCExportProviderOptions = {
const options: IPascalVOCExportProviderOptions = {
assetState: ExportAssetState.Visited,
exportUnassigned: true,
testTrainSplit: 80,
@@ -154,7 +154,7 @@ describe("TFPascalVOC Json Export Provider", () => {
const testProject = { ...baseTestProject };
testProject.tags = MockFactory.createTestTags(1);
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
const exportProvider = new PascalVOCExportProvider(testProject, options);
await exportProvider.export();
const storageProviderMock = LocalFileSystemProxy as any;
@@ -191,7 +191,7 @@ describe("TFPascalVOC Json Export Provider", () => {
});
it("Exports only tagged assets", async () => {
const options: ITFPascalVOCExportProviderOptions = {
const options: IPascalVOCExportProviderOptions = {
assetState: ExportAssetState.Tagged,
exportUnassigned: true,
testTrainSplit: 80,
@@ -200,7 +200,7 @@ describe("TFPascalVOC Json Export Provider", () => {
const testProject = { ...baseTestProject };
testProject.tags = MockFactory.createTestTags(3);
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
const exportProvider = new PascalVOCExportProvider(testProject, options);
await exportProvider.export();
const storageProviderMock = LocalFileSystemProxy as any;
@@ -242,7 +242,7 @@ describe("TFPascalVOC Json Export Provider", () => {
});
it("Export includes unassigned tags", async () => {
const options: ITFPascalVOCExportProviderOptions = {
const options: IPascalVOCExportProviderOptions = {
assetState: ExportAssetState.Tagged,
exportUnassigned: true,
testTrainSplit: 80,
@@ -254,7 +254,7 @@ describe("TFPascalVOC Json Export Provider", () => {
testProject.assets = _.keyBy(testAssets, (asset) => asset.id);
testProject.tags = MockFactory.createTestTags(3);
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
const exportProvider = new PascalVOCExportProvider(testProject, options);
await exportProvider.export();
const storageProviderMock = LocalFileSystemProxy as any;
@@ -275,7 +275,7 @@ describe("TFPascalVOC Json Export Provider", () => {
});
it("Export does not include unassigned tags", async () => {
const options: ITFPascalVOCExportProviderOptions = {
const options: IPascalVOCExportProviderOptions = {
assetState: ExportAssetState.Tagged,
exportUnassigned: false,
testTrainSplit: 80,
@@ -287,7 +287,7 @@ describe("TFPascalVOC Json Export Provider", () => {
testProject.assets = _.keyBy(testAssets, (asset) => asset.id);
testProject.tags = MockFactory.createTestTags(3);
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
const exportProvider = new PascalVOCExportProvider(testProject, options);
await exportProvider.export();
const storageProviderMock = LocalFileSystemProxy as any;
@@ -309,7 +309,7 @@ describe("TFPascalVOC Json Export Provider", () => {
describe("Annotations", () => {
it("contains expected XML", async () => {
const options: ITFPascalVOCExportProviderOptions = {
const options: IPascalVOCExportProviderOptions = {
assetState: ExportAssetState.Tagged,
exportUnassigned: false,
testTrainSplit: 80,
@@ -321,7 +321,7 @@ describe("TFPascalVOC Json Export Provider", () => {
testProject.assets = _.keyBy(testAssets, (asset) => asset.id);
testProject.tags = [MockFactory.createTestTag("1")];
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
const exportProvider = new PascalVOCExportProvider(testProject, options);
await exportProvider.export();
const storageProviderMock = LocalFileSystemProxy as any;
@@ -345,7 +345,7 @@ describe("TFPascalVOC Json Export Provider", () => {
describe("Test Train Splits", () => {
async function testTestTrainSplit(testTrainSplit: number): Promise<void> {
const options: ITFPascalVOCExportProviderOptions = {
const options: IPascalVOCExportProviderOptions = {
assetState: ExportAssetState.Tagged,
exportUnassigned: true,
testTrainSplit,
@@ -357,7 +357,7 @@ describe("TFPascalVOC Json Export Provider", () => {
testProject.assets = _.keyBy(testAssets, (asset) => asset.id);
testProject.tags = [MockFactory.createTestTag("1")];
const exportProvider = new TFPascalVOCExportProvider(testProject, options);
const exportProvider = new PascalVOCExportProvider(testProject, options);
await exportProvider.export();
const storageProviderMock = LocalFileSystemProxy as any;
@@ -1,11 +1,10 @@
import _ from "lodash";
import { ExportProvider } from "./exportProvider";
import { IProject, IAssetMetadata, RegionType, ITag, IExportProviderOptions } from "../../models/applicationState";
import { IProject, IAssetMetadata, ITag, IExportProviderOptions } from "../../models/applicationState";
import Guard from "../../common/guard";
import HtmlFileReader from "../../common/htmlFileReader";
import { itemTemplate, annotationTemplate, objectTemplate } from "./tensorFlowPascalVOC/tensorFlowPascalVOCTemplates";
import { itemTemplate, annotationTemplate, objectTemplate } from "./pascalVOC/pascalVOCTemplates";
import { interpolate } from "../../common/strings";
import { PlatformType } from "../../common/hostProcess";
import os from "os";
interface IObjectInfo {
@@ -23,9 +22,9 @@ interface IImageInfo {
}
/**
* Export options for TensorFlow Pascal VOC Export Provider
* Export options for Pascal VOC Export Provider
*/
export interface ITFPascalVOCExportProviderOptions extends IExportProviderOptions {
export interface IPascalVOCExportProviderOptions extends IExportProviderOptions {
/** The test / train split ratio for exporting data */
testTrainSplit?: number;
/** Whether or not to include unassigned tags in exported data */
@@ -33,19 +32,19 @@ export interface ITFPascalVOCExportProviderOptions extends IExportProviderOption
}
/**
* @name - TFPascalVOC Json Export Provider
* @description - Exports a project into a single JSON file that include all configured assets
* @name - PascalVOC Export Provider
* @description - Exports a project into a Pascal VOC
*/
export class TFPascalVOCExportProvider extends ExportProvider<ITFPascalVOCExportProviderOptions> {
export class PascalVOCExportProvider extends ExportProvider<IPascalVOCExportProviderOptions> {
private imagesInfo = new Map<string, IImageInfo>();
constructor(project: IProject, options: ITFPascalVOCExportProviderOptions) {
constructor(project: IProject, options: IPascalVOCExportProviderOptions) {
super(project, options);
Guard.null(options);
}
/**
* Export project to TensorFlow PascalVOC
* Export project to PascalVOC
*/
public async export(): Promise<void> {
const allAssets = await this.getAssetsForExport();
@@ -53,7 +52,7 @@ export class TFPascalVOCExportProvider extends ExportProvider<ITFPascalVOCExport
exportObject.assets = _.keyBy(allAssets, (assetMetadata) => assetMetadata.asset.id);
// Create Export Folder
const exportFolderName = `${this.project.name.replace(" ", "-")}-TFPascalVOC-export`;
const exportFolderName = `${this.project.name.replace(/\s/g, "-")}-PascalVOC-export`;
await this.storageProvider.createContainer(exportFolderName);
await this.exportImages(exportFolderName, allAssets);
@@ -31,6 +31,6 @@ export const objectTemplate = "\
<xmin>${xmin}</xmin>\n\
<ymin>${ymin}</ymin>\n\
<xmax>${xmax}</xmax>\n\
<ymax>${xmin}</ymax>\n\
<ymax>${ymax}</ymax>\n\
</bndbox>\n\
</object>";
+2 -2
Ver Arquivo
@@ -4,7 +4,7 @@ import { ExportProvider } from "./exportProvider";
import { IProject, IAssetMetadata, IExportProviderOptions } from "../../models/applicationState";
import Guard from "../../common/guard";
import HtmlFileReader from "../../common/htmlFileReader";
import { itemTemplate } from "./tensorFlowPascalVOC/tensorFlowPascalVOCTemplates";
import { itemTemplate } from "./pascalVOC/pascalVOCTemplates";
import { interpolate } from "../../common/strings";
import { TFRecordsBuilder, FeatureType } from "./tensorFlowRecords/tensorFlowBuilder";
@@ -41,7 +41,7 @@ export class TFRecordsExportProvider extends ExportProvider {
exportObject.assets = _.keyBy(allAssets, (assetMetadata) => assetMetadata.asset.id);
// Create Export Folder
const exportFolderName = `${this.project.name.replace(" ", "-")}-TFRecords-export`;
const exportFolderName = `${this.project.name.replace(/\s/g, "-")}-TFRecords-export`;
await this.storageProvider.createContainer(exportFolderName);
await this.exportPBTXT(exportFolderName, this.project);
+2 -2
Ver Arquivo
@@ -21,8 +21,8 @@
"includeImages": {
"type": "boolean",
"default": true,
"title": "${strings.export.providers.vottJson.properties.includeImages.title}",
"description": "${strings.export.providers.vottJson.properties.includeImages.description}"
"title": "${strings.export.providers.common.properties.includeImages.title}",
"description": "${strings.export.providers.common.properties.includeImages.description}"
}
}
}
+1 -1
Ver Arquivo
@@ -3,7 +3,7 @@ import { VottJsonExportProvider, IVottJsonExportProviderOptions } from "./vottJs
import registerProviders from "../../registerProviders";
import { ExportAssetState } from "./exportProvider";
import { ExportProviderFactory } from "./exportProviderFactory";
import { IProject, IAssetMetadata, AssetState, IExportProviderOptions } from "../../models/applicationState";
import { IProject, IAssetMetadata, AssetState } from "../../models/applicationState";
import MockFactory from "../../common/mockFactory";
jest.mock("../../services/assetService");
+5 -13
Ver Arquivo
@@ -1,6 +1,6 @@
import _ from "lodash";
import { ExportProvider } from "./exportProvider";
import { IProject, IExportProviderOptions, IAssetMetadata } from "../../models/applicationState";
import { IProject, IExportProviderOptions } from "../../models/applicationState";
import Guard from "../../common/guard";
import { constants } from "../../common/constants";
import HtmlFileReader from "../../common/htmlFileReader";
@@ -31,17 +31,9 @@ export class VottJsonExportProvider extends ExportProvider<IVottJsonExportProvid
if (this.options.includeImages) {
await results.forEachAsync(async (assetMetadata) => {
return new Promise<void>(async (resolve) => {
const blob = await HtmlFileReader.getAssetBlob(assetMetadata.asset);
const assetFilePath = `vott-json-export/${assetMetadata.asset.name}`;
const fileReader = new FileReader();
fileReader.onload = async () => {
const buffer = Buffer.from(fileReader.result as ArrayBuffer);
await this.storageProvider.writeBinary(assetFilePath, buffer);
resolve();
};
fileReader.readAsArrayBuffer(blob);
});
const arrayBuffer = await HtmlFileReader.getAssetArray(assetMetadata.asset);
const assetFilePath = `vott-json-export/${assetMetadata.asset.name}`;
await this.storageProvider.writeBinary(assetFilePath, Buffer.from(arrayBuffer));
});
}
@@ -53,7 +45,7 @@ export class VottJsonExportProvider extends ExportProvider<IVottJsonExportProvid
delete exportObject.targetConnection;
delete exportObject.exportFormat;
const fileName = `vott-json-export/${this.project.name.replace(" ", "-")}${constants.exportFileExtension}`;
const fileName = `vott-json-export/${this.project.name.replace(/\s/g, "-")}${constants.exportFileExtension}`;
await this.storageProvider.writeText(fileName, JSON.stringify(exportObject, null, 4));
}
}
@@ -12,6 +12,10 @@
"title": "${strings.connections.providers.azureBlob.accountName.title}",
"type": "string"
},
"accountKey": {
"title": "${strings.connections.providers.azureBlob.accountKey.title}",
"type": "string"
},
"containerName": {
"title": "${strings.connections.providers.azureBlob.containerName.title}",
"type": "string"
+7 -7
Ver Arquivo
@@ -1,11 +1,8 @@
import { IStorageProvider } from "./storageProviderFactory";
import { IAsset, AssetType, StorageType } from "../../models/applicationState";
import { Aborter, AnonymousCredential, BlockBlobURL, ContainerURL,
Credential, ServiceURL, StorageURL, TokenCredential, SharedKeyCredential } from "@azure/storage-blob";
import { AssetType, IAsset, StorageType } from "../../models/applicationState";
import { AssetService } from "../../services/assetService";
import {
TokenCredential, AnonymousCredential, ContainerURL,
StorageURL, ServiceURL, Credential, Aborter, BlockBlobURL,
} from "@azure/storage-blob";
import { BlobDeleteResponse } from "@azure/storage-blob/typings/lib/generated/lib/models";
import { IStorageProvider } from "./storageProviderFactory";
/**
* Options for Azure Cloud Storage
@@ -19,6 +16,7 @@ export interface IAzureCloudStorageOptions {
accountName: string;
containerName: string;
createContainer: boolean;
accountKey?: string;
sas?: string;
oauthToken?: string;
}
@@ -229,6 +227,8 @@ export class AzureBlobStorage implements IStorageProvider {
private getCredential(): Credential {
if (this.options.oauthToken) {
return new TokenCredential(this.options.oauthToken);
} else if (this.options.accountKey) {
return new SharedKeyCredential(this.options.accountName, this.options.accountKey);
} else {
return new AnonymousCredential();
}
@@ -12,7 +12,8 @@ export class ImageAsset extends React.Component<IAssetProps> {
<img ref={this.image}
src={this.props.asset.path}
onLoad={this.onLoad}
onError={this.props.onError} />);
onError={this.props.onError}
crossOrigin="anonymous" />);
}
private onLoad = () => {
@@ -31,7 +31,8 @@ export class TFRecordAsset extends React.Component<IAssetProps, ITFRecordState>
<img ref={this.image}
src={this.state.tfRecordImage64}
onLoad={this.onLoad}
onError={this.onError} />
onError={this.onError}
crossOrigin="anonymous" />
);
}
@@ -73,7 +73,8 @@ export class VideoAsset extends React.Component<IVideoAssetProps> {
height="100%"
autoPlay={autoPlay}
src={videoPath}
onError={this.props.onError}>
onError={this.props.onError}
crossOrigin="anonymous">
<BigPlayButton position="center" />
{autoPlay &&
<ControlBar autoHide={false}>
+15 -13
Ver Arquivo
@@ -25,20 +25,22 @@ export class ColorPicker extends React.Component<IColorPickerProps> {
private GithubPicker = () => {
return (
<GithubPicker
color={{hex: this.props.color}}
onChangeComplete={this.onChange}
colors={this.props.colors}
width={160}
styles={{
default: {
card: {
background: this.pickerBackground,
<div className="color-picker">
<GithubPicker
color={{hex: this.props.color}}
onChangeComplete={this.onChange}
colors={this.props.colors}
width={160}
styles={{
default: {
card: {
background: this.pickerBackground,
},
},
},
}}
triangle={"hide"}
/>
}}
triangle={"hide"}
/>
</div>
);
}
@@ -1,11 +1,10 @@
import React from "react";
import { mount, ReactWrapper } from "enzyme";
import axios from "axios";
import ExternalPicker, { IExternalPickerProps, IExternalPickerState } from "./externalPicker";
import ExternalPicker, { IExternalPickerProps, IExternalPickerState, FilterOperator } from "./externalPicker";
import MockFactory from "../../../../common/mockFactory";
describe("External Picker", () => {
let wrapper: ReactWrapper<IExternalPickerProps, IExternalPickerState> = null;
const onChangeHandler = jest.fn();
const defaultProps = createProps({
id: "my-custom-control",
@@ -16,12 +15,13 @@ describe("External Picker", () => {
formContext: {
providerOptions: {
apiKey: "",
region: "",
},
},
onChange: onChangeHandler,
options: {
method: "GET",
url: "https://myserver/api",
url: "https://${props.formContext.providerOptions.region}.server.com/api",
keySelector: "${item.key}",
valueSelector: "${item.value}",
authHeaderName: "Authorization",
@@ -48,32 +48,34 @@ describe("External Picker", () => {
});
});
beforeEach(() => {
wrapper = createComponent(defaultProps as IExternalPickerProps);
});
it("Renders select element with default option", () => {
const wrapper = createComponent(defaultProps);
expect(wrapper.find("select").length).toEqual(1);
expect(wrapper.find("option").length).toEqual(1);
});
it("Does not bind external data if authorization is missing", () => {
createComponent(defaultProps);
expect(axios.request).not.toBeCalled();
});
it("Renders items bound from external data when formContext rebinds", async () => {
const expectedApiKey = "ABC123";
const expectedRegion = "southcentralus";
await MockFactory.flushUi(() => {
wrapper.setProps({
formContext: {
providerOptions: {
apiKey: expectedApiKey,
},
const props = {
...defaultProps,
formContext: {
providerOptions: {
apiKey: expectedApiKey,
region: expectedRegion,
},
});
});
},
};
const wrapper = createComponent(props);
await MockFactory.flushUi();
wrapper.update();
const expectedHeaders = {};
@@ -81,7 +83,7 @@ describe("External Picker", () => {
expect(axios.request).toBeCalledWith({
method: defaultProps.options.method,
url: defaultProps.options.url,
url: `https://${expectedRegion}.server.com/api`,
headers: expectedHeaders,
});
@@ -93,19 +95,81 @@ describe("External Picker", () => {
});
it("Calls onChange event handler on option selection", () => {
wrapper.setProps({
formContext: {},
});
const wrapper = createComponent(defaultProps);
wrapper.find("select").simulate("change", { target: { value: testResponse[0].key } });
expect(onChangeHandler).toBeCalledWith(testResponse[0].key);
});
function createProps(otherProps: any): IExternalPickerProps {
it("Clears items when HTTP request fails", async () => {
const requestMock = axios.request as jest.Mock;
requestMock.mockImplementationOnce(() => Promise.reject({ status: 400 }));
const expectedApiKey = "ABC123";
const expectedRegion = "southcentralus";
const props: IExternalPickerProps = {
...otherProps,
...defaultProps,
formContext: {
providerOptions: {
apiKey: expectedApiKey,
region: expectedRegion,
},
},
};
return props;
const wrapper = createComponent(props);
await MockFactory.flushUi();
expect(wrapper.state().items).toEqual([]);
expect(onChangeHandler).toBeCalledWith(undefined);
});
describe("Filters items", () => {
it("Applies a filter to the item when defined", async () => {
const requestMock = axios.request as jest.Mock;
requestMock.mockImplementationOnce(() => Promise.resolve({
data: [
{ id: "1", name: "Object Detection 1", type: "ObjectDetection" },
{ id: "2", name: "Object Detection 2", type: "ObjectDetection" },
{ id: "3", name: "Classification 1", type: "Classification" },
{ id: "4", name: "Classification 2", type: "Classification" },
],
status: 200,
}));
const props: IExternalPickerProps = {
...defaultProps,
formContext: {
providerOptions: {
apiKey: "ABC123",
region: "southcentralus",
projectType: "Classification",
},
},
};
props.options.keySelector = "${item.id}";
props.options.valueSelector = "${item.name}";
props.options.filter = {
left: "${item.type}",
right: "${props.formContext.providerOptions.projectType}",
operator: FilterOperator.Equals,
};
const wrapper = createComponent(props);
await MockFactory.flushUi();
expect(wrapper.state().items).toEqual([
{ key: "3", value: "Classification 1" },
{ key: "4", value: "Classification 2" },
]);
});
});
function createProps(otherProps: any): IExternalPickerProps {
return {
...otherProps,
};
}
});
@@ -24,6 +24,19 @@ export interface IExternalPickerUiOptions {
valueSelector: string;
authHeaderName?: string;
authHeaderValue?: string;
filter?: IExternalPickerFilter;
}
export interface IExternalPickerFilter {
left: string;
right: string;
operator: FilterOperator;
}
export enum FilterOperator {
Equals = "eq",
GreaterThan = "gt",
LessThan = "lt",
}
/**
@@ -46,15 +59,9 @@ export interface IExternalPickerState {
* Dropdown that provides options from an external HTTP source
*/
export default class ExternalPicker extends React.Component<IExternalPickerProps, any> {
constructor(props, context) {
super(props, context);
this.state = {
items: [],
};
this.onChange = this.onChange.bind(this);
}
public state: IExternalPickerState = {
items: [],
};
public render() {
return (
@@ -78,12 +85,12 @@ export default class ExternalPicker extends React.Component<IExternalPickerProps
}
}
private onChange(e: SyntheticEvent) {
private onChange = (e: SyntheticEvent) => {
const target = e.target as HTMLSelectElement;
this.props.onChange(target.value === "" ? undefined : target.value);
}
private async bindExternalData() {
private bindExternalData = async (): Promise<void> => {
const uiOptions = this.props.options;
const customHeaders: any = {};
const authHeaderValue = interpolate(uiOptions.authHeaderValue, {
@@ -98,24 +105,52 @@ export default class ExternalPicker extends React.Component<IExternalPickerProps
const config: AxiosRequestConfig = {
method: uiOptions.method,
url: uiOptions.url,
url: interpolate(uiOptions.url, { props: this.props }),
headers: customHeaders,
};
try {
const response = await axios.request(config);
const items: IKeyValuePair[] = response.data.map((item) => {
let rawItems: any[] = response.data;
// Optionally filter results if a filter has been defined
if (uiOptions.filter) {
rawItems = rawItems.filter((item) => this.filterPredicate(item, uiOptions.filter));
}
const items: IKeyValuePair[] = rawItems.map((item) => {
return {
key: interpolate(uiOptions.keySelector, { item }),
value: interpolate(uiOptions.valueSelector, { item }),
};
});
this.setState({
items,
});
this.setState({ items });
} catch (e) {
return;
this.setState({ items: [] });
this.props.onChange(undefined);
}
}
/**
* Determines if the specified item will return as part of the filter
* @param item The item to evaluate
* @param filter The filter expression to evaluate against
*/
private filterPredicate(item: any, filter: IExternalPickerFilter): boolean {
const left = interpolate(filter.left, { item, props: this.props });
const right = interpolate(filter.right, { item, props: this.props });
switch (filter.operator) {
case FilterOperator.Equals:
return left === right;
case FilterOperator.GreaterThan:
return left > right;
case FilterOperator.LessThan:
return left < right;
default:
throw new Error("Invalid filter operator");
}
}
}
@@ -33,7 +33,7 @@ export class ProtectedInput extends React.Component<IProtectedInputProps, IProte
this.state = {
showKey: false,
value: this.props.value,
value: this.props.value || "",
};
this.toggleKeyVisibility = this.toggleKeyVisibility.bind(this);
@@ -4,6 +4,7 @@ import { TagInput, ITagInputProps, ITagInputState } from "./tagInput";
import MockFactory from "../../../../common/mockFactory";
import { ITag } from "../../../../models/applicationState";
import TagInputItem, { ITagInputItemProps } from "./tagInputItem";
import { ColorPicker } from "../colorPicker";
describe("Tag Input Component", () => {
@@ -40,6 +41,31 @@ describe("Tag Input Component", () => {
expect(props.onCtrlTagClick).not.toBeCalled();
});
it("Edits tag name when alt clicked", () => {
const props = createProps();
const wrapper = createComponent(props);
wrapper.find("div.tag-name-container").first().simulate("click", { altKey: true } );
expect(wrapper.state().editingTag).toEqual(props.tags[0]);
expect(wrapper.exists("input.tag-name-editor")).toBe(true);
});
it("Edits tag color when alt clicked", () => {
const props = createProps();
const wrapper = createComponent(props);
expect(wrapper.state().clickedColor).toBe(false);
expect(wrapper.exists("div.color-picker")).toBe(false);
wrapper.find("div.tag-color").first().simulate("click", { altKey: true } );
expect(wrapper.state().clickedColor).toBe(true);
expect(wrapper.state().showColorPicker).toBe(true);
expect(wrapper.state().editingTag).toEqual(props.tags[0]);
expect(wrapper.exists("div.color-picker")).toBe(true);
// Get color picker and call onEditColor function
const picker = wrapper.find(ColorPicker).instance() as ColorPicker;
picker.props.onEditColor("#000000");
expect(props.onChange).toBeCalled();
expect(true).toBeTruthy();
});
it("Calls onClick handler when clicking text", () => {
const props: ITagInputProps = createProps();
const wrapper = createComponent(props);
@@ -101,6 +127,34 @@ describe("Tag Input Component", () => {
expect(wrapper.state().searchTags).toBe(true);
});
it("Add tag box closed with escape key", () => {
const wrapper = createComponent();
expect(wrapper.exists(".tag-input-box")).toBe(false);
expect(wrapper.state().addTags).toBeFalsy();
wrapper.find("div.tag-input-toolbar-item.plus").simulate("click");
expect(wrapper.exists(".tag-input-box")).toBe(true);
expect(wrapper.state().addTags).toBe(true);
wrapper.find(".tag-input-box").simulate("keydown", { key: "Escape" });
expect(wrapper.exists(".tag-input-box")).toBe(false);
expect(wrapper.state().addTags).toBe(false);
});
it("Tag search box closed with escape key", async () => {
const wrapper = createComponent();
expect(wrapper.exists(".tag-search-box")).toBe(false);
expect(wrapper.state().searchTags).toBeFalsy();
wrapper.find("div.tag-input-toolbar-item.search").simulate("click");
expect(wrapper.exists(".tag-search-box")).toBe(true);
expect(wrapper.state().searchTags).toBe(true);
wrapper.find(".tag-search-box").simulate("keydown", { key: "Escape" });
await MockFactory.flushUi();
expect(wrapper.state().searchTags).toBe(false);
expect(wrapper.exists(".tag-search-box")).toBe(false);
});
it("Tag can be locked from toolbar", () => {
const tags = MockFactory.createTestTags();
const props = createProps(tags);
@@ -110,7 +164,7 @@ describe("Tag Input Component", () => {
expect(props.onLockedTagsChange).toBeCalledWith([tags[0].name]);
});
it("Tag can be edited from toolbar", () => {
it("Tag name can be edited from toolbar", () => {
const tags = MockFactory.createTestTags();
const props = createProps(tags);
const wrapper = createComponent(props);
@@ -120,6 +174,25 @@ describe("Tag Input Component", () => {
expect(wrapper.exists("input.tag-name-editor")).toBe(true);
});
it("Tag color can be edited from toolbar", () => {
const tags = MockFactory.createTestTags();
const props = createProps(tags);
const wrapper = createComponent(props);
expect(wrapper.state().clickedColor).toBe(false);
expect(wrapper.exists("div.color-picker")).toBe(false);
wrapper.find("div.tag-color").first().simulate("click");
expect(wrapper.state().clickedColor).toBe(true);
wrapper.find("div.tag-input-toolbar-item.edit").simulate("click");
expect(wrapper.state().showColorPicker).toBe(true);
expect(wrapper.state().editingTag).toEqual(tags[0]);
expect(wrapper.exists("div.color-picker")).toBe(true);
// Get color picker and call onEditColor function
const picker = wrapper.find(ColorPicker).instance() as ColorPicker;
picker.props.onEditColor("#000000");
expect(props.onChange).toBeCalled();
expect(true).toBeTruthy();
});
it("Tag can be moved up from toolbar", () => {
const tags = MockFactory.createTestTags();
const lastTag = tags[tags.length - 1];
@@ -169,6 +242,16 @@ describe("Tag Input Component", () => {
expect(props.onChange).not.toBeCalled();
});
it("Does not try to add tag with same name as existing tag", () => {
const props: ITagInputProps = {
...createProps(),
showTagInputBox: true,
};
const wrapper = createComponent(props);
wrapper.find(".tag-input-box").simulate("keydown", { key: "Enter", target: { value: props.tags[0].name } });
expect(props.onChange).not.toBeCalled();
});
it("Selects a tag", () => {
const tags = MockFactory.createTestTags();
const onChange = jest.fn();
@@ -230,6 +313,54 @@ describe("Tag Input Component", () => {
expect(onChange).toBeCalledWith(expectedTags);
});
it("Does not edit tag name with empty string", () => {
const tags = MockFactory.createTestTags();
const onChange = jest.fn();
const onTagNameChange = jest.fn();
const props = {
...createProps(tags, onChange),
onTagNameChange,
};
const wrapper = createComponent(props);
wrapper.find(".tag-content").first().simulate("click");
wrapper.find("i.tag-input-toolbar-icon.fas.fa-edit").simulate("click");
wrapper.find("input.tag-name-editor").simulate("keydown", { key: "Enter", target: { value: "" } });
expect(wrapper.state().tags).toEqual(tags);
expect(onChange).not.toBeCalled();
});
it("Does not call onChange when edited tag name is the same", () => {
const tags = MockFactory.createTestTags();
const onChange = jest.fn();
const onTagNameChange = jest.fn();
const props = {
...createProps(tags, onChange),
onTagNameChange,
};
const wrapper = createComponent(props);
wrapper.find(".tag-content").first().simulate("click");
wrapper.find("i.tag-input-toolbar-icon.fas.fa-edit").simulate("click");
wrapper.find("input.tag-name-editor").simulate("keydown", { key: "Enter", target: { value: tags[0].name } });
expect(wrapper.state().tags).toEqual(tags);
expect(onChange).not.toBeCalled();
});
it("Does not change tag name to name of other existing tag", () => {
const tags = MockFactory.createTestTags();
const onChange = jest.fn();
const onTagNameChange = jest.fn();
const props = {
...createProps(tags, onChange),
onTagNameChange,
};
const wrapper = createComponent(props);
wrapper.find(".tag-content").first().simulate("click");
wrapper.find("i.tag-input-toolbar-icon.fas.fa-edit").simulate("click");
wrapper.find("input.tag-name-editor").simulate("keydown", { key: "Enter", target: { value: tags[1].name } });
expect(wrapper.state().tags).toEqual(tags);
expect(onChange).not.toBeCalled();
});
it("Reorders a tag", () => {
const tags = MockFactory.createTestTags();
const onChange = jest.fn();
@@ -247,7 +378,20 @@ describe("Tag Input Component", () => {
expect(wrapper.state().tags.indexOf(firstTag)).toEqual(0);
});
it("set's applied tags when selected regions are available", () => {
it("Searches for a tag", () => {
const props: ITagInputProps = {
...createProps(),
showSearchBox: true,
};
const wrapper = createComponent(props);
expect(wrapper.find(".tag-item-block").length).toBeGreaterThan(1);
wrapper.find(".tag-search-box").simulate("change", { target: { value: "1" } });
expect(wrapper.state().searchQuery).toEqual("1");
expect(wrapper.find(".tag-item-block")).toHaveLength(1);
expect(wrapper.find(".tag-name-body").first().text()).toEqual("Tag 1");
});
it("sets applied tags when selected regions are available", () => {
const tags = MockFactory.createTestTags();
const onChange = jest.fn();
const props = createProps(tags, onChange);
+44 -32
Ver Arquivo
@@ -1,4 +1,4 @@
import React, { KeyboardEvent } from "react";
import React, { KeyboardEvent, RefObject } from "react";
import ReactDOM from "react-dom";
import Align from "rc-align";
import { randomIntInRange } from "../../../../common/utils";
@@ -31,9 +31,9 @@ export interface ITagInputProps {
/** Function to call on clicking individual tag while holding CTRL key */
onCtrlTagClick?: (tag: ITag) => void;
/** Function to call when tag is renamed */
onTagRenamed?: (oldTag: string, newTag: string) => void;
onTagRenamed?: (tagName: string, newTagName: string) => void;
/** Function to call when tag is deleted */
onTagDeleted?: (tag: ITag) => void;
onTagDeleted?: (tagName: string) => void;
/** Always show tag input box */
showTagInputBox?: boolean;
/** Always show tag search box */
@@ -72,7 +72,7 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
portalElement: defaultDOMNode(),
};
private tagItemRefs: { [id: string]: TagInputItem } = {};
private tagItemRefs: Map<string, TagInputItem> = new Map<string, TagInputItem>();
private portalDiv = document.createElement("div");
public render() {
@@ -98,6 +98,7 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
this.state.searchTags &&
<div className="tag-input-text-input-row search-input">
<input
className="tag-search-box"
type="text"
onKeyDown={this.onSearchKeyDown}
onChange={(e) => this.setState({ searchQuery: e.target.value })}
@@ -109,7 +110,7 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
}
{this.getColorPickerPortal()}
<div className="tag-input-items">
{this.getTagItems()}
{this.renderTagItems()}
</div>
{
this.state.addTags &&
@@ -154,21 +155,17 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
}
}
private getTagNode = (tag: ITag) => {
if (!tag) {
return defaultDOMNode();
}
return ReactDOM.findDOMNode(this.tagItemRefs[tag.name]) as Element;
private getTagNode = (tag: ITag): Element => {
const itemRef = tag ? this.tagItemRefs.get(tag.name) : null;
return (itemRef ? ReactDOM.findDOMNode(itemRef) : defaultDOMNode()) as Element;
}
private onEditTag = (tag: ITag) => {
if (!tag) {
return;
}
const { editingTag } = this.state;
const newEditingTag = (editingTag && editingTag.name === tag.name) ? null : tag;
this.setState({
editingTag: newEditingTag,
editingTagNode: this.getTagNode(newEditingTag),
});
if (this.state.clickedColor) {
this.setState({
@@ -219,20 +216,25 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
}, () => this.props.onChange(tags));
}
private updateTag = (oldTag: ITag, newTag: ITag) => {
if (oldTag === newTag) {
private updateTag = (tag: ITag, newTag: ITag) => {
if (tag.name === newTag.name && tag.color === newTag.color) {
return;
}
if (!newTag.name.length) {
toast.warn(strings.tags.warnings.emptyName);
return;
}
if (newTag.name !== oldTag.name && this.state.tags.some((t) => t.name === newTag.name)) {
const nameChange = tag.name !== newTag.name;
if (nameChange && this.state.tags.some((t) => t.name === newTag.name)) {
toast.warn(strings.tags.warnings.existingName);
return;
}
if (nameChange && this.props.onTagRenamed) {
this.props.onTagRenamed(tag.name, newTag.name);
return;
}
const tags = this.state.tags.map((t) => {
return (t.name === oldTag.name) ? newTag : t;
return (t.name === tag.name) ? newTag : t;
});
this.setState({
tags,
@@ -293,12 +295,15 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
return this.state.editingTagNode || document;
}
private getTagItems = () => {
let props = this.getTagItemProps();
private renderTagItems = () => {
let props = this.createTagItemProps();
const query = this.state.searchQuery;
this.tagItemRefs.clear();
if (query.length) {
props = props.filter((prop) => prop.tag.name.toLowerCase().includes(query.toLowerCase()));
}
return props.map((prop) =>
<TagInputItem
key={prop.tag.name}
@@ -307,17 +312,17 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
/>);
}
private setTagItemRef = (item, tag) => {
if (item) {
this.tagItemRefs[tag.name] = item;
}
private setTagItemRef = (item: TagInputItem, tag: ITag) => {
this.tagItemRefs.set(tag.name, item);
return item;
}
private getTagItemProps = (): ITagInputItemProps[] => {
private createTagItemProps = (): ITagInputItemProps[] => {
const tags = this.state.tags;
const selectedRegionTagSet = this.getSelectedRegionTagSet();
return tags.map((tag) => {
const item: ITagInputItemProps = {
return tags.map((tag) => (
{
tag,
index: tags.findIndex((t) => t.name === tag.name),
isLocked: this.props.lockedTags && this.props.lockedTags.findIndex((t) => t === tag.name) > -1,
@@ -326,9 +331,8 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
appliedToSelectedRegions: selectedRegionTagSet.has(tag.name),
onClick: this.handleClick,
onChange: this.updateTag,
};
return item;
});
} as ITagInputItemProps
));
}
private getSelectedRegionTagSet = (): Set<string> => {
@@ -346,6 +350,7 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
private onAltClick = (tag: ITag, clickedColor: boolean) => {
const { editingTag } = this.state;
const newEditingTag = editingTag && editingTag.name === tag.name ? null : tag;
this.setState({
editingTag: newEditingTag,
editingTagNode: this.getTagNode(newEditingTag),
@@ -355,16 +360,16 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
}
private handleClick = (tag: ITag, props: ITagClickProps) => {
// Lock tags
if (props.ctrlKey && this.props.onCtrlTagClick) {
this.props.onCtrlTagClick(tag);
this.setState({ clickedColor: props.clickedColor });
} else if (props.altKey) {
} else if (props.altKey) { // Edit tag
this.onAltClick(tag, props.clickedColor);
} else {
} else { // Select tag
const { editingTag, selectedTag } = this.state;
const inEditMode = editingTag && tag.name === editingTag.name;
const alreadySelected = selectedTag && selectedTag.name === tag.name;
const newEditingTag = inEditMode ? null : editingTag;
this.setState({
@@ -389,12 +394,19 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
if (!tag) {
return;
}
if (this.props.onTagDeleted) {
this.props.onTagDeleted(tag.name);
return;
}
const index = this.state.tags.indexOf(tag);
const tags = this.state.tags.filter((t) => t.name !== tag.name);
this.setState({
tags,
selectedTag: this.getNewSelectedTag(tags, index),
}, () => this.props.onChange(tags));
if (this.props.lockedTags.find((l) => l === tag.name)) {
this.props.onLockedTagsChange(
this.props.lockedTags.filter((lockedTag) => lockedTag !== tag.name),
@@ -0,0 +1,82 @@
{
"type": "object",
"properties": {
"modelPathType": {
"type": "string",
"title": "${strings.activeLearning.form.properties.modelPathType.title}",
"description": "${strings.activeLearning.form.properties.modelPathType.description}",
"enum": [
"coco",
"file",
"url"
],
"default": "coco",
"enumNames": [
"${strings.activeLearning.form.properties.modelPathType.options.preTrained}",
"${strings.activeLearning.form.properties.modelPathType.options.customFilePath}",
"${strings.activeLearning.form.properties.modelPathType.options.customWebUrl}"
]
},
"autoDetect": {
"title": "${strings.activeLearning.form.properties.autoDetect.title}",
"description": "${strings.activeLearning.form.properties.autoDetect.description}",
"type": "boolean"
},
"predictTag": {
"title": " ${strings.activeLearning.form.properties.predictTag.title}",
"description": "${strings.activeLearning.form.properties.predictTag.description}",
"type": "boolean"
}
},
"dependencies": {
"modelPathType": {
"oneOf": [
{
"properties": {
"modelPathType": {
"enum": [
"coco"
]
}
}
},
{
"required": [
"modelPath"
],
"properties": {
"modelPathType": {
"enum": [
"file"
]
},
"modelPath": {
"title": "${strings.activeLearning.form.properties.modelPath.title}",
"description": "${strings.activeLearning.form.properties.modelPath.description}",
"type": "string"
}
}
},
{
"required": [
"modelUrl"
],
"properties": {
"modelPathType": {
"enum": [
"url"
]
},
"modelUrl": {
"title": "${strings.activeLearning.form.properties.modelUrl.title}",
"description": "${strings.activeLearning.form.properties.modelUrl.description}",
"default": "http://",
"pattern": "^https?\\\\://[a-zA-Z0-9\\\\-\\\\.]+\\\\.[a-zA-Z]{2,3}(/\\\\S*)?$",
"type": "string"
}
}
}
]
}
}
}
@@ -0,0 +1,95 @@
import React from "react";
import { IActiveLearningFormProps, ActiveLearningForm, IActiveLearningFormState } from "./activeLearningForm";
import { ReactWrapper, mount } from "enzyme";
import { ModelPathType, IActiveLearningSettings } from "../../../../models/applicationState";
import Form from "react-jsonschema-form";
describe("Active Learning Form", () => {
const onChangeHandler = jest.fn();
const onSubmitHandler = jest.fn();
const onCancelHandler = jest.fn();
const defaultProps: IActiveLearningFormProps = {
settings: {
modelPathType: ModelPathType.Coco,
modelPath: null,
modelUrl: null,
autoDetect: false,
predictTag: true,
},
onChange: onChangeHandler,
onSubmit: onSubmitHandler,
onCancel: onCancelHandler,
};
function createComponent(props?: IActiveLearningFormProps)
: ReactWrapper<IActiveLearningFormProps, IActiveLearningFormState> {
props = props || defaultProps;
return mount(<ActiveLearningForm {...props} />);
}
it("renders a dynamic json schema form with default props", () => {
const wrapper = createComponent();
expect(wrapper.find(Form).exists()).toBe(true);
expect(wrapper.state().formData).toEqual(defaultProps.settings);
});
it("sets formData state when loaded with different props", () => {
const props: IActiveLearningFormProps = {
...defaultProps,
settings: {
modelPathType: ModelPathType.Url,
modelUrl: "https://myserver.com/myModel",
autoDetect: true,
predictTag: true,
},
};
const wrapper = createComponent(props);
expect(wrapper.state().formData).toEqual(props.settings);
});
it("updates form data when the props change", () => {
const wrapper = createComponent();
const newSettings: IActiveLearningSettings = {
modelPathType: ModelPathType.Url,
modelUrl: "https://myserver.com/myModel",
autoDetect: true,
predictTag: true,
};
wrapper.setProps({ settings: newSettings });
expect(wrapper.state().formData).toEqual(newSettings);
});
it("sets formData state when form changes", () => {
const wrapper = createComponent();
const formData: IActiveLearningSettings = {
modelPathType: ModelPathType.Url,
modelUrl: "https://myserver.com/myModel",
autoDetect: true,
predictTag: true,
};
// Set type to URL
wrapper.find(Form).props().onChange({ formData: { modelPathType: ModelPathType.Url } });
// Set the remaining settings
wrapper.find(Form).props().onChange({ formData });
expect(wrapper.state().formData).toEqual(formData);
expect(onChangeHandler).toBeCalledWith(formData);
});
it("submits form data to the registered submit handler", () => {
const wrapper = createComponent();
wrapper.find(Form).props().onSubmit({ formData: defaultProps.settings });
expect(onSubmitHandler).toBeCalledWith(defaultProps.settings);
});
it("raises the cancel event and called registered handler", () => {
const wrapper = createComponent();
wrapper.find(".btn-cancel").simulate("click");
expect(onCancelHandler).toBeCalled();
});
});
@@ -0,0 +1,112 @@
import React from "react";
import Form, { ISubmitEvent, IChangeEvent, Widget } from "react-jsonschema-form";
import { IActiveLearningSettings, ModelPathType } from "../../../../models/applicationState";
import { strings, addLocValues } from "../../../../common/strings";
import CustomFieldTemplate from "../../common/customField/customFieldTemplate";
import LocalFolderPicker from "../../common/localFolderPicker/localFolderPicker";
import { CustomWidget } from "../../common/customField/customField";
import Checkbox from "rc-checkbox";
// tslint:disable-next-line:no-var-requires
const formSchema = addLocValues(require("./activeLearningForm.json"));
// tslint:disable-next-line:no-var-requires
const uiSchema = addLocValues(require("./activeLearningForm.ui.json"));
export interface IActiveLearningFormProps extends React.Props<ActiveLearningForm> {
settings: IActiveLearningSettings;
onSubmit: (settings: IActiveLearningSettings) => void;
onChange?: (settings: IActiveLearningSettings) => void;
onCancel?: () => void;
}
export interface IActiveLearningFormState {
classNames: string[];
formData: IActiveLearningSettings;
uiSchema: any;
formSchema: any;
}
export class ActiveLearningForm extends React.Component<IActiveLearningFormProps, IActiveLearningFormState> {
public state: IActiveLearningFormState = {
classNames: ["needs-validation"],
uiSchema: { ...uiSchema },
formSchema: { ...formSchema },
formData: {
...this.props.settings,
},
};
private widgets = {
localFolderPicker: (LocalFolderPicker as any) as Widget,
checkbox: CustomWidget(Checkbox, (props) => ({
checked: props.value,
onChange: (value) => props.onChange(value.target.checked),
disabled: props.disabled,
})),
};
public componentDidUpdate(prevProps: Readonly<IActiveLearningFormProps>) {
if (this.props.settings !== prevProps.settings) {
this.setState({ formData: this.props.settings });
}
}
public render() {
return (
<Form
className={this.state.classNames.join(" ")}
showErrorList={false}
liveValidate={true}
noHtml5Validate={true}
FieldTemplate={CustomFieldTemplate}
widgets={this.widgets}
schema={this.state.formSchema}
uiSchema={this.state.uiSchema}
formData={this.state.formData}
onChange={this.onFormChange}
onSubmit={this.onFormSubmit}>
<div>
<button className="btn btn-success mr-1" type="submit">{strings.projectSettings.save}</button>
<button className="btn btn-secondary btn-cancel"
type="button"
onClick={this.onFormCancel}>{strings.common.cancel}</button>
</div>
</Form>
);
}
private onFormChange = (changeEvent: IChangeEvent<IActiveLearningSettings>): void => {
let updatedSettings = changeEvent.formData;
if (changeEvent.formData.modelPathType !== this.state.formData.modelPathType) {
updatedSettings = {
...changeEvent.formData,
modelPath: null,
modelUrl: null,
};
}
this.setState({
formData: updatedSettings,
}, () => {
if (this.props.onChange) {
this.props.onChange(updatedSettings);
}
});
}
private onFormSubmit = (args: ISubmitEvent<IActiveLearningSettings>): void => {
const settings: IActiveLearningSettings = {
...args.formData,
};
this.setState({ formData: settings });
this.props.onSubmit(settings);
}
private onFormCancel = (): void => {
if (this.props.onCancel) {
this.props.onCancel();
}
}
}
@@ -0,0 +1,17 @@
{
"modelPath": {
"ui:widget": "localFolderPicker"
},
"predictTag": {
"ui:widget": "checkbox"
},
"autoDetect": {
"ui:widget": "checkbox"
},
"ui:order": [
"modelPathType",
"*",
"predictTag",
"autoDetect"
]
}
@@ -0,0 +1,118 @@
import React from "react";
import ActiveLearningPage, { IActiveLearningPageProps, IActiveLearningPageState } from "./activeLearningPage";
import { ReactWrapper, mount } from "enzyme";
import { Provider } from "react-redux";
import { BrowserRouter as Router } from "react-router-dom";
import createReduxStore from "../../../../redux/store/store";
import MockFactory from "../../../../common/mockFactory";
import { ActiveLearningForm } from "./activeLearningForm";
import { IActiveLearningSettings, ModelPathType } from "../../../../models/applicationState";
jest.mock("../../../../services/projectService");
import ProjectService from "../../../../services/projectService";
import { toast } from "react-toastify";
import { strings } from "../../../../common/strings";
describe("Active Learning Page", () => {
function createComponent(store, props: IActiveLearningPageProps): ReactWrapper {
return mount(
<Provider store={store}>
<Router>
<ActiveLearningPage {...props} />
</Router>
</Provider>,
);
}
beforeAll(() => {
toast.success = jest.fn(() => 2);
});
it("renders and loads settings from props", () => {
const testProject = MockFactory.createTestProject("TestProject");
const store = createReduxStore(MockFactory.initialState({
currentProject: testProject,
}));
const props = MockFactory.activeLearningProps();
const wrapper = createComponent(store, props);
const activeLearningPage = wrapper
.find(ActiveLearningPage)
.childAt(0) as ReactWrapper<IActiveLearningPageProps, IActiveLearningPageState>;
expect(activeLearningPage.state().settings).toEqual(testProject.activeLearningSettings);
expect(wrapper.find(ActiveLearningForm).props().settings).toEqual(testProject.activeLearningSettings);
});
it("updates active learning settings if project changes", () => {
const store = createReduxStore(MockFactory.initialState());
const props = MockFactory.activeLearningProps();
const testProject = props.recentProjects[0];
const wrapper = createComponent(store, props);
const activeLearningPage = wrapper
.find(ActiveLearningPage)
.childAt(0) as ReactWrapper<IActiveLearningPageProps, IActiveLearningPageState>;
expect(activeLearningPage.state().settings).toEqual(testProject.activeLearningSettings);
expect(wrapper.find(ActiveLearningForm).props().settings).toEqual(testProject.activeLearningSettings);
});
it("saves the active learning settings when the form is submitted", async () => {
const testProject = MockFactory.createTestProject("TestProject");
const activeLearningSettings: IActiveLearningSettings = {
...testProject.activeLearningSettings,
modelPathType: ModelPathType.Url,
modelUrl: "http://myserver.com/custommodel",
autoDetect: true,
predictTag: true,
};
const store = createReduxStore(MockFactory.initialState({
currentProject: testProject,
}));
const projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
projectServiceMock.prototype.load = jest.fn((project) => Promise.resolve(project));
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
const props = MockFactory.activeLearningProps();
const saveProjectSpy = jest.spyOn(props.actions, "saveProject");
saveProjectSpy.mockClear();
const wrapper = createComponent(store, props);
const activeLearningForm = wrapper.find(ActiveLearningForm);
activeLearningForm.props().onSubmit(activeLearningSettings);
await MockFactory.flushUi();
expect(saveProjectSpy).toBeCalledWith(expect.objectContaining({
...testProject,
activeLearningSettings,
}));
expect(toast.success).toBeCalledWith(strings.activeLearning.messages.saveSuccess);
expect(props.history.goBack).toBeCalled();
});
it("returns to the previous page when the form is cancelled", async () => {
const testProject = MockFactory.createTestProject("TestProject");
const store = createReduxStore(MockFactory.initialState({
currentProject: testProject,
}));
const props = MockFactory.activeLearningProps();
const saveProjectSpy = jest.spyOn(props.actions, "saveProject");
saveProjectSpy.mockClear();
const wrapper = createComponent(store, props);
wrapper.find(ActiveLearningForm).props().onCancel();
await MockFactory.flushUi();
expect(props.history.goBack).toBeCalled();
expect(saveProjectSpy).not.toBeCalled();
});
});
@@ -0,0 +1,93 @@
import React from "react";
import { connect } from "react-redux";
import { RouteComponentProps } from "react-router";
import { bindActionCreators } from "redux";
import { IActiveLearningSettings, IProject, IApplicationState } from "../../../../models/applicationState";
import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions";
import { strings } from "../../../../common/strings";
import { ActiveLearningForm } from "./activeLearningForm";
import { toast } from "react-toastify";
export interface IActiveLearningPageProps extends RouteComponentProps, React.Props<ActiveLearningPage> {
project: IProject;
recentProjects: IProject[];
actions: IProjectActions;
}
export interface IActiveLearningPageState {
settings: IActiveLearningSettings;
}
function mapStateToProps(state: IApplicationState) {
return {
project: state.currentProject,
recentProjects: state.recentProjects,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(projectActions, dispatch),
};
}
@connect(mapStateToProps, mapDispatchToProps)
export default class ActiveLearningPage extends React.Component<IActiveLearningPageProps, IActiveLearningPageState> {
public state: IActiveLearningPageState = {
settings: this.props.project ? this.props.project.activeLearningSettings : null,
};
public async componentDidMount() {
const projectId = this.props.match.params["projectId"];
// If we are creating a new project check to see if there is a partial
// project already created in local storage
if (!this.props.project && projectId) {
const projectToLoad = this.props.recentProjects.find((project) => project.id === projectId);
if (projectToLoad) {
await this.props.actions.loadProject(projectToLoad);
}
}
}
public componentDidUpdate(prevProps: Readonly<IActiveLearningPageProps>) {
if (prevProps.project !== this.props.project) {
this.setState({ settings: this.props.project.activeLearningSettings });
}
}
public render() {
return (
<div className="project-settings-page">
<div className="project-settings-page-settings m-3">
<h3>
<i className="fas fa-graduation-cap" />
<span className="px-2">
{strings.activeLearning.title}
</span>
</h3>
<div className="m-3">
<ActiveLearningForm
settings={this.state.settings}
onSubmit={this.onFormSubmit}
onCancel={this.onFormCancel} />
</div>
</div>
</div>
);
}
private onFormSubmit = async (settings: IActiveLearningSettings): Promise<void> => {
const updatedProject: IProject = {
...this.props.project,
activeLearningSettings: settings,
};
await this.props.actions.saveProject(updatedProject);
toast.success(strings.activeLearning.messages.saveSuccess);
this.props.history.goBack();
}
private onFormCancel = (): void => {
this.props.history.goBack();
}
}
@@ -1,9 +0,0 @@
import React from "react";
export default class ActiveLearningPage extends React.Component {
public render() {
return (
<div>ActiveLearningPage</div>
);
}
}
@@ -45,6 +45,7 @@ describe("Editor Canvas", () => {
const canvasProps: ICanvasProps = {
selectedAsset: getAssetMetadata(),
onAssetMetadataChanged: jest.fn(),
onCanvasRendered: jest.fn(),
editorMode: EditorMode.Rectangle,
selectionMode: SelectionMode.RECT,
project: MockFactory.createTestProject(),
@@ -179,7 +180,7 @@ describe("Editor Canvas", () => {
});
const canvas = wrapper.instance() as Canvas;
expect(wrapper.state().currentAsset).toEqual(assetMetadata);
expect(() => canvas.updateCanvasToolsRegions()).not.toThrowError();
expect(() => canvas.updateCanvasToolsRegionTags()).not.toThrowError();
});
it("canvas content source is updated when asset is deactivated", () => {
+19 -5
Ver Arquivo
@@ -4,7 +4,7 @@ import { CanvasTools } from "vott-ct";
import { RegionData } from "vott-ct/lib/js/CanvasTools/Core/RegionData";
import {
EditorMode, IAssetMetadata,
IProject, IRegion, RegionType, IBoundingBox, ISize,
IProject, IRegion, RegionType,
} from "../../../../models/applicationState";
import CanvasHelpers from "./canvasHelpers";
import { AssetPreview, ContentSource } from "../../common/assetPreview/assetPreview";
@@ -25,6 +25,7 @@ export interface ICanvasProps extends React.Props<Canvas> {
children?: ReactElement<AssetPreview>;
onAssetMetadataChanged?: (assetMetadata: IAssetMetadata) => void;
onSelectedRegionsChanged?: (regions: IRegion[]) => void;
onCanvasRendered?: (canvas: HTMLCanvasElement) => void;
}
export interface ICanvasState {
@@ -73,7 +74,8 @@ export default class Canvas extends React.Component<ICanvasProps, ICanvasState>
}
public componentDidUpdate = async (prevProps: Readonly<ICanvasProps>, prevState: Readonly<ICanvasState>) => {
if (this.props.selectedAsset.asset.id !== prevProps.selectedAsset.asset.id) {
// Handles asset changing
if (this.props.selectedAsset !== prevProps.selectedAsset) {
this.setState({ currentAsset: this.props.selectedAsset });
}
@@ -83,9 +85,16 @@ export default class Canvas extends React.Component<ICanvasProps, ICanvasState>
this.editor.AS.setSelectionMode({ mode: this.props.selectionMode, template: options });
}
const assetIdChanged = this.state.currentAsset.asset.id !== prevState.currentAsset.asset.id;
// When the selected asset has changed but is still the same asset id
if (!assetIdChanged && this.state.currentAsset !== prevState.currentAsset) {
this.refreshCanvasToolsRegions();
}
// When the project tags change re-apply tags to regions
if (this.props.project.tags !== prevProps.project.tags) {
this.updateCanvasToolsRegions();
this.updateCanvasToolsRegionTags();
}
// Handles when the canvas is enabled & disabled
@@ -192,7 +201,7 @@ export default class Canvas extends React.Component<ICanvasProps, ICanvasState>
return this.state.currentAsset.regions.filter((r) => selectedRegions.find((id) => r.id === id));
}
public updateCanvasToolsRegions = (): void => {
public updateCanvasToolsRegionTags = (): void => {
for (const region of this.state.currentAsset.regions) {
this.editor.RM.updateTagsById(
region.id,
@@ -447,6 +456,11 @@ export default class Canvas extends React.Component<ICanvasProps, ICanvasState>
private setContentSource = async (contentSource: ContentSource) => {
try {
await this.editor.addContentSource(contentSource as any);
if (this.props.onCanvasRendered) {
const canvas = this.canvasZone.current.querySelector("canvas");
this.props.onCanvasRendered(canvas);
}
} catch (e) {
console.warn(e);
}
@@ -493,7 +507,7 @@ export default class Canvas extends React.Component<ICanvasProps, ICanvasState>
this.editor.RM.updateTagsById(update.id, CanvasHelpers.getTagsDescriptor(this.props.project.tags, update));
}
this.updateAssetRegions(updatedRegions);
this.updateCanvasToolsRegions();
this.updateCanvasToolsRegionTags();
}
/**
@@ -1,166 +0,0 @@
import { mount, ReactWrapper } from "enzyme";
import React from "react";
import EditorFooter, { IEditorFooterProps, IEditorFooterState } from "./editorFooter";
import MockFactory from "../../../../common/mockFactory";
describe("Footer Component", () => {
const originalTags = MockFactory.createTestTags();
function createComponent(props: IEditorFooterProps): ReactWrapper<IEditorFooterProps, IEditorFooterState> {
return mount(
<EditorFooter {...props} />,
);
}
it("tags are initialized correctly", () => {
const wrapper = createComponent({
tags: originalTags,
lockedTags: [],
});
const stateTags = wrapper.state().tags;
expect(stateTags).toEqual(originalTags);
});
it("tags are empty", () => {
const wrapper = createComponent({
tags: [],
lockedTags: [],
});
const stateTags = wrapper.state()["tags"];
expect(stateTags).toEqual([]);
});
it("create a new tag from text box", () => {
const onChangeHandler = jest.fn();
const wrapper = createComponent({
tags: originalTags,
lockedTags: [],
onTagsChanged: onChangeHandler,
});
const newTagName = "My new tag";
wrapper.find("input").simulate("change", { target: { value: newTagName } });
wrapper.find("input").simulate("keyDown", { keyCode: 13 });
expect(onChangeHandler).toBeCalled();
const tags = wrapper.state().tags;
expect(tags).toHaveLength(originalTags.length + 1);
expect(tags[tags.length - 1].name).toEqual(newTagName);
});
it("create a new tag when no tags exist", () => {
const onChangeHandler = jest.fn();
const wrapper = createComponent({
tags: null,
lockedTags: [],
onTagsChanged: onChangeHandler,
});
const newTagName = "My new tag";
wrapper.find("input").simulate("change", { target: { value: newTagName } });
wrapper.find("input").simulate("keyDown", { keyCode: 13 });
expect(onChangeHandler).toBeCalled();
const tags = wrapper.state().tags;
expect(tags).toHaveLength(1);
expect(tags[0].name).toEqual(newTagName);
});
it("remove a tag", () => {
const onChangeHandler = jest.fn();
const wrapper = createComponent({
tags: originalTags,
lockedTags: [],
onTagsChanged: onChangeHandler,
});
expect(wrapper.state().tags).toHaveLength(originalTags.length);
wrapper.find("a.ReactTags__remove")
.last().simulate("click");
expect(onChangeHandler).toBeCalled();
const tags = wrapper.state().tags;
expect(tags).toHaveLength(originalTags.length - 1);
expect(tags[0].name).toEqual(originalTags[0].name);
expect(tags[0].color).toEqual(originalTags[0].color);
});
it("clicking 'ok' in modal closes and calls onChangeHandler", () => {
const onChangeHandler = jest.fn();
const wrapper = createComponent({
tags: originalTags,
lockedTags: [],
onTagsChanged: onChangeHandler,
});
wrapper.find("div.tag")
.first()
.simulate("click", { target: { innerText: originalTags[0].name }, shiftKey: true});
wrapper.find("button.btn.btn-success").simulate("click");
expect(onChangeHandler).toBeCalled();
});
it("clicking 'cancel' in modal closes and does not call onChangeHandler", () => {
const onChangeHandler = jest.fn();
const wrapper = createComponent({
tags: originalTags,
lockedTags: [],
onTagsChanged: onChangeHandler,
});
wrapper.find("div.tag")
.first()
.simulate("click", { target: { innerText: originalTags[0].name }, shiftKey: true});
wrapper.find("button.btn.btn-secondary").simulate("click");
expect(onChangeHandler).not.toBeCalled();
});
it("clicking tag calls onTagClickHandler ", () => {
const onChangeHandler = jest.fn();
const onTagClickHandler = jest.fn();
const wrapper = createComponent({
tags: originalTags,
lockedTags: [],
onTagsChanged: onChangeHandler,
onTagClicked: onTagClickHandler,
});
wrapper.find("div.tag")
.first()
.simulate("click", { target: { innerText: originalTags[0].name }});
expect(onTagClickHandler).toBeCalledWith(originalTags[0]);
});
it("clicking tag with ctrl does not call onTagClickHandler ", () => {
const onChangeHandler = jest.fn();
const onTagClickHandler = jest.fn();
const wrapper = createComponent({
tags: originalTags,
lockedTags: [],
onTagsChanged: onChangeHandler,
onTagClicked: onTagClickHandler,
});
wrapper.find("div.tag")
.first()
.simulate("click", { target: { innerText: originalTags[0].name }, shiftKey: true});
expect(onTagClickHandler).not.toBeCalled();
});
it("componentDidUpdate check", async () => {
const onChangeHandler = jest.fn();
const onTagClickHandler = jest.fn();
const wrapper = createComponent({
tags: originalTags,
lockedTags: [],
onTagsChanged: onChangeHandler,
onTagClicked: onTagClickHandler,
});
wrapper.setProps({tags: [...originalTags, {color: "#808000", name: "NEWTAG"}]});
await MockFactory.flushUi();
wrapper.update();
const tagChild = wrapper.find("div.tag");
expect(tagChild.length).toEqual(originalTags.length + 1);
expect(tagChild.last().text()).toMatch("NEWTAG");
});
});
@@ -1,134 +0,0 @@
import React from "react";
import { TagEditorModal, TagsInput } from "vott-react";
import { strings } from "../../../../common/strings";
import { ITag } from "../../../../models/applicationState";
/**
* Properties for Editor Footer
* @member tags - Array of tags for TagsInput component
* @member lockedTags - Tags currently locked for applying to regions
* @member displayHotKeys - Determines whether indices for first 10 tags are shown on tag buttons
* @member onTagsChanged - Function to call when tags are changed
* @member onTagClicked - Function to call when tags are clicked
*/
export interface IEditorFooterProps {
tags: ITag[];
lockedTags: string[];
onTagsChanged?: (value) => void;
onTagClicked?: (value) => void;
onCtrlTagClicked?: (value) => void;
onCtrlShiftTagClicked?: (value) => void;
}
/**
* State for Editor Footer
* @member tags - Array of tags for TagsInput component
*/
export interface IEditorFooterState {
tags: ITag[];
selectedTag: ITag;
}
/**
* @name - Editor Footer
* @description - Footer of the editor page. Contains EditorTagsInput component
*/
export default class EditorFooter extends React.Component<IEditorFooterProps, IEditorFooterState> {
public state = {
tags: this.props.tags,
selectedTag: null,
};
private tagsInput: React.RefObject<TagsInput> = React.createRef<TagsInput>();
private tagEditorModal: React.RefObject<TagEditorModal> = React.createRef<TagEditorModal>();
public componentDidUpdate(prevProp: IEditorFooterProps) {
if (prevProp.tags !== this.props.tags) {
this.setState({
tags: this.props.tags,
});
}
}
public render() {
return (
<div>
<TagsInput
tags={this.state.tags}
ref={this.tagsInput}
onChange={this.onTagsChanged}
onTagClick={this.onTagClicked}
onCtrlTagClick={this.props.onCtrlTagClicked}
onShiftTagClick={this.onShiftTagClicked}
onCtrlShiftTagClick={this.props.onCtrlShiftTagClicked}
getTagSpan={this.getTagSpan}
/>
<TagEditorModal
ref={this.tagEditorModal}
onOk={this.onTagModalOk}
tagNameText={strings.tags.modal.name}
tagColorText={strings.tags.modal.color}
saveText={strings.common.save}
cancelText={strings.common.cancel}
/>
</div>
);
}
private onTagClicked = (tag: ITag) => {
this.props.onTagClicked(tag);
this.blurTagsInput();
}
private onShiftTagClicked = (tag: ITag) => {
this.setState({
selectedTag: tag,
}, () => {
this.tagEditorModal.current.open(tag);
});
}
private onTagModalOk = (oldTag: ITag, newTag: ITag) => {
this.tagsInput.current.updateTag(oldTag, newTag);
this.tagEditorModal.current.close();
}
private onTagsChanged = (tags) => {
this.setState({
tags,
}, () => {
this.props.onTagsChanged(this.state);
this.blurTagsInput();
});
}
/**
* Shows the display index of the tag in the span of the first 10 tags
* Also adds necessary stylings to all locked tags
* @param name Name of tag
* @param index Index of tag
*/
private getTagSpan = (name: string, index: number) => {
let className = "tag-span";
let displayName = name;
if (index < 10) {
const displayIndex = (index === 9) ? 0 : index + 1;
displayName = `[${displayIndex}] ` + name;
className += " tag-span-index";
}
if (this.props.lockedTags.find((t) => t === name)) {
className += " locked-tag";
}
return (
<span className={className}>{displayName}</span>
);
}
private blurTagsInput() {
const inputElement: HTMLElement = document.querySelector(".ReactTags__tagInputField");
if (inputElement) {
setImmediate(() => inputElement.blur());
}
}
}
@@ -8,7 +8,7 @@ import EditorPage, { IEditorPageProps, IEditorPageState } from "./editorPage";
import MockFactory from "../../../../common/mockFactory";
import {
IApplicationState, IAssetMetadata, IProject,
EditorMode, IAsset, AssetState, AssetType, ISize,
EditorMode, IAsset, AssetState, ISize, IActiveLearningSettings, ModelPathType,
} from "../../../../models/applicationState";
import { AssetProviderFactory } from "../../../../providers/storage/assetProviderFactory";
import createReduxStore from "../../../../redux/store/store";
@@ -29,6 +29,11 @@ import { appInfo } from "../../../../common/appInfo";
import SplitPane from "react-split-pane";
import EditorSideBar from "./editorSideBar";
import Alert from "../../common/alert/alert";
import registerMixins from "../../../../registerMixins";
import { TagInput } from "../../common/tagInput/tagInput";
import { EditorToolbar } from "./editorToolbar";
import { ToolbarItem } from "../../toolbar/toolbarItem";
import { ActiveLearningService } from "../../../../services/activeLearningService";
function createComponent(store, props: IEditorPageProps): ReactWrapper<IEditorPageProps, IEditorPageState, EditorPage> {
return mount(
@@ -58,9 +63,20 @@ describe("Editor Page Component", () => {
let assetServiceMock: jest.Mocked<typeof AssetService> = null;
let projectServiceMock: jest.Mocked<typeof ProjectService> = null;
const electronMock = {
remote: {
app: {
getAppPath: jest.fn(() => ""),
},
},
};
const testAssets: IAsset[] = MockFactory.createTestAssets(5);
beforeAll(() => {
registerToolbar();
window["require"] = jest.fn(() => electronMock);
const editorMock = Editor as any;
editorMock.prototype.addContentSource = jest.fn(() => Promise.resolve());
editorMock.prototype.scaleRegionToSourceSize = jest.fn((regionData: any) => regionData);
@@ -125,12 +141,10 @@ describe("Editor Page Component", () => {
const wrapper = createComponent(store, props);
const editorPage = wrapper.find(EditorPage).childAt(0);
expect(getState(wrapper).project).toBeNull();
editorPage.props().project = testProject;
await MockFactory.flushUi();
expect(editorPage.props().project).toEqual(testProject);
expect(getState(wrapper).project).toEqual(testProject);
});
it("Loads and merges project assets with asset provider assets when state changes", async () => {
@@ -163,7 +177,7 @@ describe("Editor Page Component", () => {
});
});
it("Raises onAssetSelected handler when an asset is selected from the sidebar", async () => {
it("Default asset is loaded and saved during initial page rendering", async () => {
// create test project and asset
const testProject = MockFactory.createTestProject("TestProject");
const defaultAsset = testAssets[0];
@@ -181,6 +195,7 @@ describe("Editor Page Component", () => {
const editorPage = wrapper.find(EditorPage).childAt(0) as ReactWrapper<IEditorPageProps, IEditorPageState>;
await MockFactory.flushUi();
wrapper.update();
const expectedAsset = editorPage.state().assets[0];
const partialProject = {
@@ -333,45 +348,6 @@ describe("Editor Page Component", () => {
expect(saveProjectSpy).toBeCalledWith(expect.objectContaining(partialProject));
});
describe("Editor Page Component Forcing Tag Scenario", () => {
it("Detect new Tag from asset metadata when selecting the Asset", async () => {
const getAssetMetadataMock = assetServiceMock.prototype.getAssetMetadata as jest.Mock;
getAssetMetadataMock.mockImplementationOnce((asset) => {
const assetMetadata: IAssetMetadata = {
asset: { ...asset },
regions: [{ ...MockFactory.createTestRegion(), tags: ["NEWTAG"] }],
version: appInfo.version,
};
return Promise.resolve(assetMetadata);
});
// create test project and asset
const testProject = MockFactory.createTestProject("TestProject");
// mock store and props
const store = createStore(testProject, true);
const props = MockFactory.editorPageProps(testProject.id);
const saveProjectSpy = jest.spyOn(props.actions, "saveProject");
// create mock editor page
createComponent(store, props);
const partialProjectToBeSaved = {
id: testProject.id,
name: testProject.name,
tags: expect.arrayContaining([{
name: "NEWTAG",
color: expect.any(String),
}]),
};
await MockFactory.flushUi();
expect(saveProjectSpy).toBeCalledWith(expect.objectContaining(partialProjectToBeSaved));
});
});
it("When an image is updated the asset metadata is updated", async () => {
const testProject = MockFactory.createTestProject("TestProject");
const store = createStore(testProject, true);
@@ -497,7 +473,6 @@ describe("Editor Page Component", () => {
const removeAllRegionsConfirm = jest.fn();
beforeAll(() => {
registerToolbar();
const clipboard = (navigator as any).clipboard;
if (!(clipboard && clipboard.writeText)) {
(navigator as any).clipboard = {
@@ -657,6 +632,11 @@ describe("Editor Page Component", () => {
});
describe("Basic tag interaction tests", () => {
beforeAll(() => {
registerMixins();
});
it("tags are initialized correctly", () => {
const project = MockFactory.createTestProject();
const store = createReduxStore({
@@ -665,26 +645,31 @@ describe("Editor Page Component", () => {
});
const wrapper = createComponent(store, MockFactory.editorPageProps());
expect(getState(wrapper).project.tags).toEqual(project.tags);
expect(wrapper.find(TagInput).props().tags).toEqual(project.tags);
});
it("create a new tag from text box", () => {
it("create a new tag updates project tags", async () => {
const project = MockFactory.createTestProject();
const store = createReduxStore({
...MockFactory.initialState(),
currentProject: project,
});
const wrapper = createComponent(store, MockFactory.editorPageProps());
expect(getState(wrapper).project.tags).toEqual(project.tags);
await waitForSelectedAsset(wrapper);
const newTagName = "My new tag";
wrapper.find("div.tag-input-toolbar-item.plus").simulate("click");
wrapper.find(".tag-input-box").simulate("keydown", { key: "Enter", target: { value: newTagName } });
const newTag = MockFactory.createTestTag("NewTag");
const updatedTags = [...project.tags, newTag];
wrapper.find(TagInput).props().onChange(updatedTags);
const stateTags = getState(wrapper).project.tags;
await MockFactory.flushUi();
wrapper.update();
expect(stateTags).toHaveLength(project.tags.length + 1);
expect(stateTags[stateTags.length - 1].name).toEqual(newTagName);
const editorPage = wrapper.find(EditorPage).childAt(0) as ReactWrapper<IEditorPageProps>;
const projectTags = editorPage.props().project.tags;
expect(projectTags).toHaveLength(updatedTags.length);
expect(projectTags[projectTags.length - 1].name).toEqual(newTag.name);
});
it("Remove a tag", async () => {
@@ -697,12 +682,20 @@ describe("Editor Page Component", () => {
const wrapper = createComponent(store, MockFactory.editorPageProps());
await waitForSelectedAsset(wrapper);
expect(getState(wrapper).project.tags).toEqual(project.tags);
wrapper.find(".tag-content").last().simulate("click");
wrapper.find("i.tag-input-toolbar-icon.fas.fa-trash").simulate("click");
const tagToDelete = project.tags[project.tags.length - 1];
wrapper.find(TagInput).props().onTagDeleted(tagToDelete.name);
const stateTags = getState(wrapper).project.tags;
expect(stateTags).toHaveLength(project.tags.length - 1);
// Accept the modal delete warning
wrapper.update();
wrapper.find(".modal-footer button").first().simulate("click");
await MockFactory.flushUi();
wrapper.update();
const editorPage = wrapper.find(EditorPage).childAt(0) as ReactWrapper<IEditorPageProps>;
const projectTags = editorPage.props().project.tags;
expect(projectTags).toHaveLength(project.tags.length - 1);
});
it("Adds tag to locked tags when CmdOrCtrl clicked", async () => {
@@ -807,6 +800,72 @@ describe("Editor Page Component", () => {
}));
});
});
describe("Active Learning", async () => {
let wrapper: ReactWrapper;
let editorPage: ReactWrapper<IEditorPageProps, IEditorPageState>;
const activeLearningMock = ActiveLearningService as jest.Mocked<typeof ActiveLearningService>;
async function beforeActiveLearningTest(activeLearningSettings?: IActiveLearningSettings) {
document.querySelector = MockFactory.mockCanvas();
activeLearningMock.prototype.isModelLoaded = jest.fn(() => true);
activeLearningMock.prototype.predictRegions = jest.fn((canvas, assetMetadtata) => {
return Promise.resolve({
...assetMetadtata,
predicted: true,
});
});
const project = MockFactory.createTestProject();
if (activeLearningSettings) {
project.activeLearningSettings = activeLearningSettings;
}
const store = createReduxStore({
...MockFactory.initialState(),
currentProject: project,
});
wrapper = createComponent(store, MockFactory.editorPageProps());
await waitForSelectedAsset(wrapper);
wrapper.update();
editorPage = wrapper.find(EditorPage).childAt(0);
}
it("predicts regions when auto detect has been enabled", async () => {
const activeLearningSettings: IActiveLearningSettings = {
modelPathType: ModelPathType.Coco,
autoDetect: true,
predictTag: true,
};
await beforeActiveLearningTest(activeLearningSettings);
editorPage.find(Canvas).props().onCanvasRendered(document.createElement("canvas"));
expect(activeLearningMock.prototype.predictRegions).toBeCalled();
});
it("predicts regions when toolbar item is selected", async () => {
await beforeActiveLearningTest();
const toolbarItem = {
props: {
name: ToolbarItemName.ActiveLearning,
},
};
const selectedAsset = editorPage.state().selectedAsset;
wrapper.find(EditorToolbar).props().onToolbarItemSelected(toolbarItem as ToolbarItem);
await MockFactory.flushUi();
expect(activeLearningMock.prototype.predictRegions).toBeCalledWith(expect.anything(), selectedAsset);
expect(assetServiceMock.prototype.save).toBeCalledWith({
...selectedAsset,
predicted: true,
});
});
});
});
function createStore(project: IProject, setCurrentProject: boolean = false): Store<any, AnyAction> {
@@ -10,14 +10,14 @@ import { strings } from "../../../../common/strings";
import {
AssetState, AssetType, EditorMode, IApplicationState,
IAppSettings, IAsset, IAssetMetadata, IProject, IRegion,
ISize, ITag,
ISize, ITag, IAdditionalPageSettings, AppError, ErrorCode,
} from "../../../../models/applicationState";
import { IToolbarItemRegistration, ToolbarItemFactory } from "../../../../providers/toolbar/toolbarItemFactory";
import IApplicationActions, * as applicationActions from "../../../../redux/actions/applicationActions";
import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions";
import { ToolbarItemName } from "../../../../registerToolbar";
import { AssetService } from "../../../../services/assetService";
import { AssetPreview, IAssetPreviewSettings } from "../../common/assetPreview/assetPreview";
import { AssetPreview } from "../../common/assetPreview/assetPreview";
import { KeyboardBinding } from "../../common/keyboardBinding/keyboardBinding";
import { KeyEventType } from "../../common/keyboardManager/keyboardManager";
import { TagInput } from "../../common/tagInput/tagInput";
@@ -28,8 +28,9 @@ import "./editorPage.scss";
import EditorSideBar from "./editorSideBar";
import { EditorToolbar } from "./editorToolbar";
import Alert from "../../common/alert/alert";
// tslint:disable-next-line:no-var-requires
const tagColors = require("../../common/tagColors.json");
import Confirm from "../../common/confirm/confirm";
import { ActiveLearningService } from "../../../../services/activeLearningService";
import { toast } from "react-toastify";
/**
* Properties for Editor Page
@@ -50,8 +51,6 @@ export interface IEditorPageProps extends RouteComponentProps, React.Props<Edito
* State for Editor Page
*/
export interface IEditorPageState {
/** Project being editor */
project: IProject;
/** Array of assets in project */
assets: IAsset[];
/** The editor mode to set for canvas tools */
@@ -65,7 +64,7 @@ export interface IEditorPageState {
/** The child assets used for nest asset typs */
childAssets?: IAsset[];
/** Additional settings for asset previews */
additionalSettings?: IAssetPreviewSettings;
additionalSettings?: IAdditionalPageSettings;
/** Most recently selected tag */
selectedTag: string;
/** Tags locked for region labeling */
@@ -102,24 +101,28 @@ function mapDispatchToProps(dispatch) {
*/
@connect(mapStateToProps, mapDispatchToProps)
export default class EditorPage extends React.Component<IEditorPageProps, IEditorPageState> {
public state: IEditorPageState = {
project: this.props.project,
selectedTag: null,
lockedTags: [],
selectionMode: SelectionMode.RECT,
assets: [],
childAssets: [],
editorMode: EditorMode.Rectangle,
additionalSettings: { videoSettings: (this.props.project) ? this.props.project.videoSettings : null },
additionalSettings: {
videoSettings: (this.props.project) ? this.props.project.videoSettings : null,
activeLearningSettings: (this.props.project) ? this.props.project.activeLearningSettings : null,
},
thumbnailSize: this.props.appSettings.thumbnailSize || { width: 175, height: 155 },
isValid: true,
showInvalidRegionWarning: false,
};
private activeLearningService: ActiveLearningService = null;
private loadingProjectAssets: boolean = false;
private toolbarItems: IToolbarItemRegistration[] = ToolbarItemFactory.getToolbarItems();
private canvas: RefObject<Canvas> = React.createRef();
private renameTagConfirm: React.RefObject<Confirm> = React.createRef();
private deleteTagConfirm: React.RefObject<Confirm> = React.createRef();
public async componentDidMount() {
const projectId = this.props.match.params["projectId"];
@@ -129,9 +132,11 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
const project = this.props.recentProjects.find((project) => project.id === projectId);
await this.props.actions.loadProject(project);
}
this.activeLearningService = new ActiveLearningService(this.props.project.activeLearningSettings);
}
public async componentDidUpdate() {
public async componentDidUpdate(prevProps: Readonly<IEditorPageProps>) {
if (this.props.project && this.state.assets.length === 0) {
await this.loadProjectAssets();
}
@@ -139,12 +144,18 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
// Navigating directly to the page via URL (ie, http://vott/projects/a1b2c3dEf/edit) sets the default state
// before props has been set, this updates the project and additional settings to be valid once props are
// retrieved.
if (!this.state.project && this.props.project) {
if (this.props.project && !prevProps.project) {
this.setState({
project: this.props.project,
additionalSettings: { videoSettings: (this.props.project) ? this.props.project.videoSettings : null },
additionalSettings: {
videoSettings: (this.props.project) ? this.props.project.videoSettings : null,
activeLearningSettings: (this.props.project) ? this.props.project.activeLearningSettings : null,
},
});
}
if (this.props.project && prevProps.project && this.props.project.tags !== prevProps.project.tags) {
this.updateRootAssets();
}
}
public render() {
@@ -206,6 +217,7 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
ref={this.canvas}
selectedAsset={this.state.selectedAsset}
onAssetMetadataChanged={this.onAssetMetadataChanged}
onCanvasRendered={this.onCanvasRendered}
onSelectedRegionsChanged={this.onSelectedRegionsChanged}
editorMode={this.state.editorMode}
selectionMode={this.state.selectionMode}
@@ -232,8 +244,20 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
onLockedTagsChange={this.onLockedTagsChanged}
onTagClick={this.onTagClicked}
onCtrlTagClick={this.onCtrlTagClicked}
onTagRenamed={this.confirmTagRenamed}
onTagDeleted={this.confirmTagDeleted}
/>
</div>
<Confirm title={strings.editorPage.tags.rename.title}
ref={this.renameTagConfirm}
message={strings.editorPage.tags.rename.confirmation}
confirmButtonColor="danger"
onConfirm={this.onTagRenamed} />
<Confirm title={strings.editorPage.tags.delete.title}
ref={this.deleteTagConfirm}
message={strings.editorPage.tags.delete.confirmation}
confirmButtonColor="danger"
onConfirm={this.onTagDeleted} />
</div>
</SplitPane>
<Alert show={this.state.showInvalidRegionWarning}
@@ -288,6 +312,49 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
}, () => this.canvas.current.applyTag(tag.name));
}
/**
* Open confirm dialog for tag renaming
*/
private confirmTagRenamed = (tagName: string, newTagName: string): void => {
this.renameTagConfirm.current.open(tagName, newTagName);
}
/**
* Renames tag in assets and project, and saves files
* @param tagName Name of tag to be renamed
* @param newTagName New name of tag
*/
private onTagRenamed = async (tagName: string, newTagName: string): Promise<void> => {
const assetUpdates = await this.props.actions.updateProjectTag(this.props.project, tagName, newTagName);
const selectedAsset = assetUpdates.find((am) => am.asset.id === this.state.selectedAsset.asset.id);
if (selectedAsset) {
if (selectedAsset) {
this.setState({ selectedAsset });
}
}
}
/**
* Open Confirm dialog for tag deletion
*/
private confirmTagDeleted = (tagName: string): void => {
this.deleteTagConfirm.current.open(tagName);
}
/**
* Removes tag from assets and projects and saves files
* @param tagName Name of tag to be deleted
*/
private onTagDeleted = async (tagName: string): Promise<void> => {
const assetUpdates = await this.props.actions.deleteProjectTag(this.props.project, tagName);
const selectedAsset = assetUpdates.find((am) => am.asset.id === this.state.selectedAsset.asset.id);
if (selectedAsset) {
this.setState({ selectedAsset });
}
}
private onCtrlTagClicked = (tag: ITag): void => {
const locked = this.state.lockedTags;
this.setState({
@@ -419,21 +486,28 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
this.setState({ childAssets, assets, isValid: true });
}
/**
* Raised when the asset binary has been painted onto the canvas tools rendering canvas
*/
private onCanvasRendered = async (canvas: HTMLCanvasElement) => {
// When active learning auto-detect is enabled
// run predictions when asset changes
if (this.props.project.activeLearningSettings.autoDetect && !this.state.selectedAsset.asset.predicted) {
await this.predictRegions(canvas);
}
}
private onSelectedRegionsChanged = (selectedRegions: IRegion[]) => {
this.setState({ selectedRegions });
}
private onTagsChanged = (tags) => {
private onTagsChanged = async (tags) => {
const project = {
...this.props.project,
tags,
};
this.setState({ project }, async () => {
await this.props.actions.saveProject(project);
if (this.canvas.current) {
this.canvas.current.updateCanvasToolsRegions();
}
});
await this.props.actions.saveProject(project);
}
private onLockedTagsChanged = (lockedTags: string[]) => {
@@ -484,6 +558,41 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
case ToolbarItemName.RemoveAllRegions:
this.canvas.current.confirmRemoveAllRegions();
break;
case ToolbarItemName.ActiveLearning:
await this.predictRegions();
break;
}
}
private predictRegions = async (canvas?: HTMLCanvasElement) => {
canvas = canvas || document.querySelector("canvas");
if (!canvas) {
return;
}
// Load the configured ML model
if (!this.activeLearningService.isModelLoaded()) {
let toastId: number = null;
try {
toastId = toast.info(strings.activeLearning.messages.loadingModel, { autoClose: false });
await this.activeLearningService.ensureModelLoaded();
} catch (e) {
toast.error(strings.activeLearning.messages.errorLoadModel);
return;
} finally {
toast.dismiss(toastId);
}
}
// Predict and add regions to current asset
try {
const updatedAssetMetadata = await this.activeLearningService
.predictRegions(canvas, this.state.selectedAsset);
await this.onAssetMetadataChanged(updatedAssetMetadata);
this.setState({ selectedAsset: updatedAssetMetadata });
} catch (e) {
throw new AppError(ErrorCode.ActiveLearningPredictionError, "Error predicting regions");
}
}
@@ -523,7 +632,6 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
}
const assetMetadata = await this.props.actions.loadAssetMetadata(this.props.project, asset);
await this.updateProjectTagsFromAsset(assetMetadata);
try {
if (!assetMetadata.asset.size) {
@@ -541,32 +649,6 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
});
}
private async updateProjectTagsFromAsset(asset: IAssetMetadata) {
const assetTags = new Set();
asset.regions.forEach((region) => region.tags.forEach((tag) => assetTags.add(tag)));
const newTags: ITag[] = this.props.project.tags ? [...this.props.project.tags] : [];
let updateTags = false;
assetTags.forEach((tag) => {
if (!this.props.project.tags || this.props.project.tags.length === 0 ||
!this.props.project.tags.find((projectTag) => tag === projectTag.name)) {
newTags.push({
name: tag,
color: tagColors[newTags.length % tagColors.length],
});
updateTags = true;
}
});
if (updateTags) {
asset.asset.state = AssetState.Tagged;
const newProject = { ...this.props.project, tags: newTags };
await this.props.actions.saveAssetMetadata(newProject, asset);
await this.props.actions.saveProject(newProject);
}
}
private loadProjectAssets = async (): Promise<void> => {
if (this.loadingProjectAssets || this.state.assets.length > 0) {
return;
@@ -598,4 +680,19 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
this.loadingProjectAssets = false;
});
}
/**
* Updates the root asset list from the project assets
*/
private updateRootAssets = () => {
const updatedAssets = [...this.state.assets];
updatedAssets.forEach((asset) => {
const projectAsset = this.props.project.assets[asset.id];
if (projectAsset) {
asset.state = projectAsset.state;
}
});
this.setState({ assets: updatedAssets });
}
}
@@ -52,6 +52,7 @@ describe("Export Form Component", () => {
providerType: "vottJson",
providerOptions: {
assetState: ExportAssetState.Tagged,
includeImages: true,
},
},
onSubmit: onSubmitHandler,
@@ -1,6 +1,7 @@
import React from "react";
import _ from "lodash";
import Form, { Widget, FormValidation, IChangeEvent, ISubmitEvent } from "react-jsonschema-form";
import { getDefaultFormState } from "react-jsonschema-form/lib/utils";
import { addLocValues, strings } from "../../../../common/strings";
import { IExportFormat, IExportProviderOptions } from "../../../../models/applicationState";
import { ExportProviderFactory } from "../../../../providers/export/exportProviderFactory";
@@ -115,7 +116,7 @@ export default class ExportForm extends React.Component<IExportFormProps, IExpor
if (providerType !== this.state.providerName) {
this.bindForm(args.formData, true);
} else {
this.setState({ formData: { ...args.formData } });
this.bindForm(args.formData, false);
}
}
@@ -160,11 +161,11 @@ export default class ExportForm extends React.Component<IExportFormProps, IExpor
}
const formData = { ...exportFormat };
if (resetProviderOptions) {
formData.providerOptions = {} as IExportProviderOptions;
}
const providerOptions = resetProviderOptions ? {} : exportFormat.providerOptions;
const providerDefaults = getDefaultFormState(newFormSchema.properties.providerOptions, providerOptions);
formData.providerType = providerType;
formData.providerOptions = providerDefaults as IExportProviderOptions;
this.setState({
providerName: providerType,
@@ -1,9 +0,0 @@
import React from "react";
export default class ProfileSettingsPage extends React.Component {
public render() {
return (
<div>ProfileSettingsPage</div>
);
}
}
@@ -15,8 +15,9 @@ describe("Project Form Component", () => {
const appSettings = MockFactory.appSettings();
const connections = MockFactory.createTestConnections();
let wrapper: ReactWrapper<IProjectFormProps, IProjectFormState> = null;
let onSubmitHandler: jest.Mock = null;
let onCancelHandler: jest.Mock = null;
const onSubmitHandler = jest.fn();
const onChangeHandler = jest.fn();
const onCancelHandler = jest.fn();
function createComponent(props: IProjectFormProps) {
return mount(
@@ -33,13 +34,16 @@ describe("Project Form Component", () => {
describe("Completed project", () => {
beforeEach(() => {
onSubmitHandler = jest.fn();
onCancelHandler = jest.fn();
onChangeHandler.mockClear();
onSubmitHandler.mockClear();
onCancelHandler.mockClear();
wrapper = createComponent({
project,
connections,
appSettings,
onSubmit: onSubmitHandler,
onChange: onChangeHandler,
onCancel: onCancelHandler,
});
});
@@ -76,10 +80,14 @@ describe("Project Form Component", () => {
const form = wrapper.find("form");
form.simulate("submit");
expect(onSubmitHandler).toBeCalledWith({
const expectedProject = {
...project,
name: newName,
});
};
expect(onChangeHandler).toBeCalled();
expect(onSubmitHandler).toBeCalledWith(expectedProject);
});
it("starting project should update description upon submission", () => {
@@ -92,10 +100,14 @@ describe("Project Form Component", () => {
const form = wrapper.find("form");
form.simulate("submit");
expect(onSubmitHandler).toBeCalledWith({
const expectedProject = {
...project,
description: newDescription,
});
};
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
expect(onSubmitHandler).toBeCalledWith(expectedProject);
});
it("starting project should update source connection ID upon submission", () => {
@@ -109,11 +121,14 @@ describe("Project Form Component", () => {
expect(wrapper.state().formData.sourceConnection).toEqual(newConnection);
const form = wrapper.find("form");
form.simulate("submit");
expect(onSubmitHandler).toBeCalledWith({
const expectedProject = {
...project,
sourceConnection: connections[1],
});
};
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
expect(onSubmitHandler).toBeCalledWith(expectedProject);
});
it("starting project should update target connection ID upon submission", () => {
@@ -125,13 +140,17 @@ describe("Project Form Component", () => {
wrapper.find("select#root_targetConnection").simulate("change", { target: { value: newConnection.id } });
expect(wrapper.state().formData.targetConnection).toEqual(newConnection);
wrapper.find("form").simulate("submit");
expect(onSubmitHandler).toBeCalledWith({
const expectedProject = {
...project,
targetConnection: connections[1],
});
};
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
expect(onSubmitHandler).toBeCalledWith(expectedProject);
});
it("starting project should call onChangeHandler on submission", () => {
it("starting project should call onSubmitHandler on submission", () => {
const form = wrapper.find("form");
form.simulate("submit");
expect(onSubmitHandler).toBeCalledWith({
@@ -155,6 +174,7 @@ describe("Project Form Component", () => {
const form = wrapper.find("form");
form.simulate("submit");
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
expect(onSubmitHandler).toBeCalledWith(
expect.objectContaining({
name: newName,
@@ -187,6 +207,7 @@ describe("Project Form Component", () => {
appSettings,
connections: newConnections,
onSubmit: onSubmitHandler,
onChange: onChangeHandler,
onCancel: onCancelHandler,
});
// Source Connection should have all connections
@@ -202,13 +223,12 @@ describe("Project Form Component", () => {
describe("Empty Project", () => {
beforeEach(() => {
onSubmitHandler = jest.fn();
onCancelHandler = jest.fn();
wrapper = createComponent({
project: null,
appSettings,
connections,
onSubmit: onSubmitHandler,
onChange: onChangeHandler,
onCancel: onCancelHandler,
});
});
@@ -239,6 +259,7 @@ describe("Project Form Component", () => {
appSettings,
connections,
onSubmit: onSubmitHandler,
onChange: onChangeHandler,
onCancel: onCancelHandler,
});
const newTagName = "My new tag";
@@ -1,5 +1,5 @@
import React from "react";
import Form, { FormValidation, ISubmitEvent } from "react-jsonschema-form";
import Form, { FormValidation, ISubmitEvent, IChangeEvent, Widget } from "react-jsonschema-form";
import { ITagsInputProps, TagEditorModal, TagsInput } from "vott-react";
import { addLocValues, strings } from "../../../../common/strings";
import { IConnection, IProject, ITag, IAppSettings } from "../../../../models/applicationState";
@@ -10,6 +10,7 @@ import CustomFieldTemplate from "../../common/customField/customFieldTemplate";
import { ISecurityTokenPickerProps, SecurityTokenPicker } from "../../common/securityTokenPicker/securityTokenPicker";
import "vott-react/dist/css/tagsInput.css";
import { IConnectionProviderPickerProps } from "../../common/connectionProviderPicker/connectionProviderPicker";
import LocalFolderPicker from "../../common/localFolderPicker/localFolderPicker";
// tslint:disable-next-line:no-var-requires
const formSchema = addLocValues(require("./projectForm.json"));
@@ -28,6 +29,7 @@ export interface IProjectFormProps extends React.Props<ProjectForm> {
connections: IConnection[];
appSettings: IAppSettings;
onSubmit: (project: IProject) => void;
onChange?: (project: IProject) => void;
onCancel?: () => void;
}
@@ -50,6 +52,10 @@ export interface IProjectFormState {
* @description - Form for editing or creating VoTT projects
*/
export default class ProjectForm extends React.Component<IProjectFormProps, IProjectFormState> {
private widgets = {
localFolderPicker: (LocalFolderPicker as any) as Widget,
};
private tagsInput: React.RefObject<TagsInput>;
private tagEditorModal: React.RefObject<TagEditorModal>;
@@ -94,9 +100,11 @@ export default class ProjectForm extends React.Component<IProjectFormProps, IPro
FieldTemplate={CustomFieldTemplate}
validate={this.onFormValidate}
fields={this.fields()}
widgets={this.widgets}
schema={this.state.formSchema}
uiSchema={this.state.uiSchema}
formData={this.state.formData}
onChange={this.onFormChange}
onSubmit={this.onFormSubmit}>
<div>
<button className="btn btn-success mr-1" type="submit">{strings.projectSettings.save}</button>
@@ -184,6 +192,12 @@ export default class ProjectForm extends React.Component<IProjectFormProps, IPro
return errors;
}
private onFormChange = (changeEvent: IChangeEvent<IProject>) => {
if (this.props.onChange) {
this.props.onChange(changeEvent.formData);
}
}
private onFormSubmit(args: ISubmitEvent<IProject>) {
const project: IProject = {
...args.formData,
@@ -4,20 +4,21 @@ import { Provider } from "react-redux";
import { BrowserRouter as Router } from "react-router-dom";
import MockFactory from "../../../../common/mockFactory";
import createReduxStore from "../../../../redux/store/store";
import ProjectSettingsPage, { IProjectSettingsPageProps } from "./projectSettingsPage";
import ProjectSettingsPage, { IProjectSettingsPageProps, IProjectSettingsPageState } from "./projectSettingsPage";
jest.mock("../../../../services/projectService");
import ProjectService from "../../../../services/projectService";
import { IAppSettings } from "../../../../models/applicationState";
import { IAppSettings, IProject } from "../../../../models/applicationState";
import ProjectMetrics from "./projectMetrics";
import ProjectForm, { IProjectFormProps } from "./projectForm";
jest.mock("./projectMetrics", () => () => {
return (
<div className="project-settings-page-metrics">
Dummy Project Metrics
</div>
);
},
return (
<div className="project-settings-page-metrics">
Dummy Project Metrics
</div>
);
},
);
describe("Project settings page", () => {
@@ -33,12 +34,29 @@ describe("Project settings page", () => {
);
}
beforeEach(() => {
projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
projectServiceMock.prototype.load = jest.fn((project) => ({...project}));
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
};
beforeAll(() => {
Object.defineProperty(global, "_localStorage", {
value: localStorageMock,
writable: false,
});
});
it("Form submission calls save project action", (done) => {
beforeEach(() => {
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
localStorageMock.removeItem.mockClear();
projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
projectServiceMock.prototype.load = jest.fn((project) => ({ ...project }));
});
it("Form submission calls save project action", async () => {
const store = createReduxStore(MockFactory.initialState());
const props = MockFactory.projectSettingsProps();
const saveProjectSpy = jest.spyOn(props.projectActions, "saveProject");
@@ -47,14 +65,12 @@ describe("Project settings page", () => {
const wrapper = createComponent(store, props);
wrapper.find("form").simulate("submit");
await MockFactory.flushUi();
setImmediate(() => {
expect(saveProjectSpy).toBeCalled();
done();
});
expect(saveProjectSpy).toBeCalled();
});
it("Throws an error when a user tries to create a duplicate project", async (done) => {
it("Throws an error when a user tries to create a duplicate project", async () => {
const project = MockFactory.createTestProject("1");
project.id = "25";
const initialStateOverride = {
@@ -78,18 +94,16 @@ describe("Project settings page", () => {
},
});
wrapper.find("form").simulate("submit");
setImmediate(async () => {
// expect(saveProjectSpy).toBeCalled();
expect(saveProjectSpy.mockRejectedValue).not.toBeNull();
done();
});
await MockFactory.flushUi();
expect(saveProjectSpy.mockRejectedValue).not.toBeNull();
});
it("calls save project when user creates a unique project", (done) => {
it("calls save project when user creates a unique project", async () => {
const initialState = MockFactory.initialState();
// New Project should not have id or security token set by default
const project = {...initialState.recentProjects[0]};
const project = { ...initialState.recentProjects[0] };
project.id = null;
project.name = "Brand New Project";
project.securityToken = "";
@@ -106,20 +120,20 @@ describe("Project settings page", () => {
const wrapper = createComponent(store, props);
wrapper.find("form").simulate("submit");
setImmediate(() => {
// New security token was created for new project
expect(saveAppSettingsSpy).toBeCalled();
const appSettings = saveAppSettingsSpy.mock.calls[0][0] as IAppSettings;
expect(appSettings.securityTokens.length).toEqual(initialState.appSettings.securityTokens.length + 1);
await MockFactory.flushUi();
// New project was saved with new security token
expect(saveProjectSpy).toBeCalledWith({
...project,
securityToken: `${project.name} Token`,
});
// New security token was created for new project
expect(saveAppSettingsSpy).toBeCalled();
const appSettings = saveAppSettingsSpy.mock.calls[0][0] as IAppSettings;
expect(appSettings.securityTokens.length).toEqual(initialState.appSettings.securityTokens.length + 1);
done();
// New project was saved with new security token
expect(saveProjectSpy).toBeCalledWith({
...project,
securityToken: `${project.name} Token`,
});
expect(localStorage.removeItem).toBeCalledWith("projectForm");
});
it("render ProjectMetrics", () => {
@@ -146,4 +160,64 @@ describe("Project settings page", () => {
expect(projectMetrics).toHaveLength(0);
});
});
describe("Persisting project form", () => {
let wrapper: ReactWrapper = null;
function initPersistProjectFormTest() {
const store = createReduxStore(MockFactory.initialState());
const props = MockFactory.projectSettingsProps();
props.match.url = "/projects/create";
wrapper = createComponent(store, props);
}
it("Loads partial project from local storage", () => {
const partialProject: IProject = {
...{} as any,
name: "partial project",
description: "partial project description",
tags: [
{ name: "tag-1", color: "#ff0000" },
{ name: "tag-3", color: "#ffff00" },
],
};
localStorageMock.getItem.mockImplementationOnce(() => JSON.stringify(partialProject));
initPersistProjectFormTest();
const projectSettingsPage = wrapper
.find(ProjectSettingsPage)
.childAt(0) as ReactWrapper<IProjectSettingsPageProps, IProjectSettingsPageState>;
expect(localStorage.getItem).toBeCalledWith("projectForm");
expect(projectSettingsPage.state().project).toEqual(partialProject);
});
it("Stores partial project in local storage", () => {
initPersistProjectFormTest();
const partialProject: IProject = {
...{} as any,
name: "partial project",
};
const projectForm = wrapper.find(ProjectForm) as ReactWrapper<IProjectFormProps>;
projectForm.props().onChange(partialProject);
expect(localStorage.setItem).toBeCalledWith("projectForm", JSON.stringify(partialProject));
});
it("Does NOT store empty project in local storage", () => {
initPersistProjectFormTest();
const emptyProject: IProject = {
...{} as any,
sourceConnection: {},
targetConnection: {},
exportFormat: {},
};
const projectForm = wrapper.find(ProjectForm) as ReactWrapper<IProjectFormProps>;
projectForm.props().onChange(emptyProject);
expect(localStorage.setItem).not.toBeCalled();
});
});
});
@@ -27,6 +27,10 @@ export interface IProjectSettingsPageProps extends RouteComponentProps, React.Pr
appSettings: IAppSettings;
}
export interface IProjectSettingsPageState {
project: IProject;
}
function mapStateToProps(state: IApplicationState) {
return {
project: state.currentProject,
@@ -43,24 +47,40 @@ function mapDispatchToProps(dispatch) {
};
}
const projectFormTempKey = "projectForm";
/**
* @name - Project Settings Page
* @description - Page for adding/editing/removing projects
*/
@connect(mapStateToProps, mapDispatchToProps)
export default class ProjectSettingsPage extends React.Component<IProjectSettingsPageProps> {
constructor(props, context) {
super(props, context);
export default class ProjectSettingsPage extends React.Component<IProjectSettingsPageProps, IProjectSettingsPageState> {
public state: IProjectSettingsPageState = {
project: this.props.project,
};
public async componentDidMount() {
const projectId = this.props.match.params["projectId"];
if (!this.props.project && projectId) {
const project = this.props.recentProjects.find((project) => project.id === projectId);
this.props.applicationActions.ensureSecurityToken(project);
this.props.projectActions.loadProject(project);
// If we are creating a new project check to see if there is a partial
// project already created in local storage
if (this.props.match.url === "/projects/create") {
const projectJson = localStorage.getItem(projectFormTempKey);
if (projectJson) {
this.setState({ project: JSON.parse(projectJson) });
}
} else if (!this.props.project && projectId) {
const projectToLoad = this.props.recentProjects.find((project) => project.id === projectId);
if (projectToLoad) {
await this.props.applicationActions.ensureSecurityToken(projectToLoad);
await this.props.projectActions.loadProject(projectToLoad);
}
}
}
this.onFormSubmit = this.onFormSubmit.bind(this);
this.onFormCancel = this.onFormCancel.bind(this);
public componentDidUpdate(prevProps: Readonly<IProjectSettingsPageProps>) {
if (prevProps.project !== this.props.project) {
this.setState({ project: this.props.project });
}
}
public render() {
@@ -75,9 +95,10 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
</h3>
<div className="m-3">
<ProjectForm
project={this.props.project}
project={this.state.project}
connections={this.props.connections}
appSettings={this.props.appSettings}
onChange={this.onFormChange}
onSubmit={this.onFormSubmit}
onCancel={this.onFormCancel} />
</div>
@@ -91,11 +112,23 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
);
}
/**
* When the project form is changed verifies if the project contains enough information
* to persist into temp local storage to support better new project flow when
* creating new connections inline
*/
private onFormChange = (project: IProject) => {
if (this.isPartialProject(project)) {
localStorage.setItem(projectFormTempKey, JSON.stringify(project));
}
}
private onFormSubmit = async (project: IProject) => {
const isNew = !(!!project.id);
await this.props.applicationActions.ensureSecurityToken(project);
await this.props.projectActions.saveProject(project);
localStorage.removeItem(projectFormTempKey);
toast.success(interpolate(strings.projectSettings.messages.saveSuccess, { project }));
@@ -106,7 +139,23 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
}
}
private onFormCancel() {
private onFormCancel = () => {
localStorage.removeItem(projectFormTempKey);
this.props.history.goBack();
}
/**
* Checks whether a project is partially populated
*/
private isPartialProject = (project: IProject): boolean => {
return project && !(!!project.id) &&
(
!!project.name
|| !!project.description
|| (project.sourceConnection && Object.keys(project.sourceConnection).length > 0)
|| (project.targetConnection && Object.keys(project.targetConnection).length > 0)
|| (project.exportFormat && Object.keys(project.exportFormat).length > 0)
|| (project.tags && project.tags.length > 0)
);
}
}
@@ -11,7 +11,6 @@ import MainContentRouter from "./mainContentRouter";
import HomePage, { IHomePageProps } from "./../pages/homepage/homePage";
import SettingsPage from "./../pages/appSettings/appSettingsPage";
import ConnectionsPage from "./../pages/connections/connectionsPage";
import ProfilePage from "./../pages/profileSettingsPage";
import { IApplicationState } from "./../../../models/applicationState";
describe("Main Content Router", () => {
@@ -43,7 +42,6 @@ describe("Main Content Router", () => {
expect(pathMap["/"]).toBe(HomePage);
expect(pathMap["/settings"]).toBe(SettingsPage);
expect(pathMap["/connections"]).toBe(ConnectionsPage);
expect(pathMap["/profile"]).toBe(ProfilePage);
});
it("renders a redirect when no route is matched", () => {
@@ -1,13 +1,12 @@
import React from "react";
import { Switch, Route, Redirect } from "react-router-dom";
import { Switch, Route } from "react-router-dom";
import HomePage from "../pages/homepage/homePage";
import ActiveLearningPage from "../pages/activeLearningPage";
import ActiveLearningPage from "../pages/activeLearning/activeLearningPage";
import AppSettingsPage from "../pages/appSettings/appSettingsPage";
import ConnectionPage from "../pages/connections/connectionsPage";
import EditorPage from "../pages/editorPage/editorPage";
import ExportPage from "../pages/export/exportPage";
import ProjectSettingsPage from "../pages/projectSettings/projectSettingsPage";
import ProfileSettingsPage from "../pages/profileSettingsPage";
/**
* @name - Main Content Router
@@ -19,7 +18,6 @@ export default function MainContentRouter() {
<Switch>
<Route path="/" exact component={HomePage} />
<Route path="/settings" component={AppSettingsPage} />
<Route path="/profile" component={ProfileSettingsPage} />
<Route path="/connections/:connectionId" component={ConnectionPage} />
<Route path="/connections" exact component={ConnectionPage} />
<Route path="/projects/:projectId/edit" component={EditorPage} />
+1 -1
Ver Arquivo
@@ -16,6 +16,6 @@ describe("Sidebar Component", () => {
expect(wrapper).not.toBeNull();
const links = wrapper.find("ul li");
expect(links.length).toEqual(6);
expect(links.length).toEqual(7);
});
});
+10 -1
Ver Arquivo
@@ -39,7 +39,16 @@ export default function Sidebar({ project }) {
<ConditionalNavLink disabled={!projectId}
title={strings.export.title}
to={`/projects/${projectId}/export`}>
<i className="fas fa-external-link-square-alt"></i></ConditionalNavLink></li>
<i className="fas fa-external-link-square-alt"></i>
</ConditionalNavLink>
</li>
<li>
<ConditionalNavLink disabled={!projectId}
title={strings.activeLearning.title}
to={`/projects/${projectId}/active-learning`}>
<i className="fas fa-graduation-cap"></i>
</ConditionalNavLink>
</li>
<li>
<NavLink title={strings.connections.title}
to={`/connections`}><i className="fas fa-plug"></i></NavLink>
+2
Ver Arquivo
@@ -16,6 +16,8 @@ export enum ActionTypes {
CLOSE_PROJECT_SUCCESS = "CLOSE_PROJECT_SUCCESS",
LOAD_PROJECT_ASSETS_SUCCESS = "LOAD_PROJECT_ASSETS_SUCCESS",
EXPORT_PROJECT_SUCCESS = "EXPORT_PROJECT_SUCCESS",
UPDATE_PROJECT_TAG_SUCCESS = "UPDATE_PROJECT_TAG_SUCCESS",
DELETE_PROJECT_TAG_SUCCESS = "DELETE_PROJECT_TAG_SUCCESS",
// Connections
LOAD_CONNECTION_SUCCESS = "LOAD_CONNECTION_SUCCESS",
+85 -35
Ver Arquivo
@@ -1,3 +1,4 @@
import _ from "lodash";
import createMockStore, { MockStoreEnhanced } from "redux-mock-store";
import { ActionTypes } from "./actionTypes";
import * as projectActions from "./projectActions";
@@ -10,25 +11,30 @@ import ProjectService from "../../services/projectService";
jest.mock("../../services/assetService");
import { AssetService } from "../../services/assetService";
import { ExportProviderFactory } from "../../providers/export/exportProviderFactory";
import { ExportAssetState, IExportProvider } from "../../providers/export/exportProvider";
import { IApplicationState } from "../../models/applicationState";
import { IExportProvider } from "../../providers/export/exportProvider";
import { IApplicationState, IProject } from "../../models/applicationState";
import initialState from "../store/initialState";
import { appInfo } from "../../common/appInfo";
import registerMixins from "../../registerMixins";
describe("Project Redux Actions", () => {
let store: MockStoreEnhanced<IApplicationState>;
let projectServiceMock: jest.Mocked<typeof ProjectService>;
const appSettings = MockFactory.appSettings();
beforeAll(registerMixins);
beforeEach(() => {
const middleware = [thunk];
const mockState: IApplicationState = {
...initialState,
appSettings,
};
store = createMockStore<IApplicationState>(middleware)(mockState);
projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
projectServiceMock.prototype.load = jest.fn((project) => Promise.resolve(project));
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
});
it("Load Project action resolves a promise and dispatches redux action", async () => {
@@ -81,39 +87,6 @@ describe("Project Redux Actions", () => {
expect(result.version).toEqual(appInfo.version);
});
it("Save Project action on new project correctly add default export format", async () => {
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
const skeletonProject = MockFactory.createTestProject("TestProject");
const project = {
...skeletonProject,
exportFormat: null,
};
const result = await projectActions.saveProject(project)(store.dispatch, store.getState);
expect(result.exportFormat).toEqual({
providerType: "vottJson",
providerOptions: {
assetState: ExportAssetState.Visited,
},
});
});
it("Save Project action on new project correctly set tags to empty if none created", async () => {
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
const skeletonProject = MockFactory.createTestProject("TestProject");
const project = {
...skeletonProject,
tags: null,
};
const result = await projectActions.saveProject(project)(store.dispatch, store.getState);
expect(result.tags).toEqual([]);
});
it("Save Project action does not override existing export format", async () => {
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
@@ -254,4 +227,81 @@ describe("Project Redux Actions", () => {
expect(mockExportProvider.export).toHaveBeenCalled();
});
describe("Updating project tags", () => {
let project: IProject = null;
beforeEach(() => {
project = MockFactory.createTestProject("TestProject");
const middleware = [thunk];
const mockState: IApplicationState = {
...initialState,
currentProject: project,
appSettings,
};
store = createMockStore<IApplicationState>(middleware)(mockState);
});
it("Updates tags across all project assets when a tag is renamed", async () => {
const projectAssets = _.values(project.assets);
const updatedTag = project.tags[project.tags.length - 1];
const updatedAssets = [
MockFactory.createTestAssetMetadata(projectAssets[0]),
MockFactory.createTestAssetMetadata(projectAssets[1]),
];
const expectedTagName = `${updatedTag.name} - updated`;
const assetServiceMock = AssetService as jest.Mocked<typeof AssetService>;
assetServiceMock.prototype.renameTag = jest.fn(() => Promise.resolve(updatedAssets));
const actualUpdatedAssets = await projectActions.updateProjectTag(
project,
updatedTag.name,
expectedTagName,
)(store.dispatch, store.getState);
const actions = store.getActions();
expect(actions.length).toEqual(5);
expect(actions[0].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS);
expect(actions[1].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS);
expect(actions[2].type).toEqual(ActionTypes.SAVE_PROJECT_SUCCESS);
expect(actions[3].type).toEqual(ActionTypes.LOAD_PROJECT_SUCCESS);
expect(actions[4].type).toEqual(ActionTypes.UPDATE_PROJECT_TAG_SUCCESS);
expect(actualUpdatedAssets).toEqual(updatedAssets);
});
it("Deletes tags across all project assets when a tag is renamed", async () => {
const projectAssets = _.values(project.assets);
const deletedTag = project.tags[project.tags.length - 1];
const updatedAssets = [
MockFactory.createTestAssetMetadata(projectAssets[0]),
MockFactory.createTestAssetMetadata(projectAssets[1]),
];
const assetServiceMock = AssetService as jest.Mocked<typeof AssetService>;
assetServiceMock.prototype.deleteTag = jest.fn(() => Promise.resolve(updatedAssets));
const actualUpdatedAssets = await projectActions.deleteProjectTag(
project,
deletedTag.name,
)(store.dispatch, store.getState);
const actions = store.getActions();
expect(actions.length).toEqual(5);
expect(actions[0].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS);
expect(actions[1].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS);
expect(actions[2].type).toEqual(ActionTypes.SAVE_PROJECT_SUCCESS);
expect(actions[3].type).toEqual(ActionTypes.LOAD_PROJECT_SUCCESS);
expect(actions[4].type).toEqual(ActionTypes.DELETE_PROJECT_TAG_SUCCESS);
expect(actualUpdatedAssets).toEqual(updatedAssets);
});
});
});
+96 -19
Ver Arquivo
@@ -15,6 +15,8 @@ import { createAction, createPayloadAction, IPayloadAction } from "./actionCreat
import { ExportAssetState, IExportResults } from "../../providers/export/exportProvider";
import { appInfo } from "../../common/appInfo";
import { strings } from "../../common/strings";
import { IExportFormat } from "vott-react";
import { IVottJsonExportProviderOptions } from "../../providers/export/vottJson";
/**
* Actions to be performed in relation to projects
@@ -28,6 +30,8 @@ export default interface IProjectActions {
loadAssets(project: IProject): Promise<IAsset[]>;
loadAssetMetadata(project: IProject, asset: IAsset): Promise<IAssetMetadata>;
saveAssetMetadata(project: IProject, assetMetadata: IAssetMetadata): Promise<IAssetMetadata>;
updateProjectTag(project: IProject, oldTagName: string, newTagName: string): Promise<IAssetMetadata[]>;
deleteProjectTag(project: IProject, tagName): Promise<IAssetMetadata[]>;
}
/**
@@ -76,21 +80,7 @@ export function saveProject(project: IProject)
throw new AppError(ErrorCode.SecurityTokenNotFound, "Security Token Not Found");
}
const defaultExportFormat = {
providerType: "vottJson",
providerOptions: {
assetState: ExportAssetState.Visited,
},
};
const newProject = {
...project,
version: appInfo.version,
exportFormat: project.exportFormat || defaultExportFormat,
tags: project.tags || [],
};
const savedProject = await projectService.save(newProject, projectToken);
const savedProject = await projectService.save(project, projectToken);
dispatch(saveProjectAction(savedProject));
// Reload project after save actions
@@ -130,7 +120,7 @@ export function deleteProject(project: IProject)
*/
export function closeProject(): (dispatch: Dispatch) => void {
return (dispatch: Dispatch): void => {
dispatch({type: ActionTypes.CLOSE_PROJECT_SUCCESS});
dispatch({ type: ActionTypes.CLOSE_PROJECT_SUCCESS });
};
}
@@ -159,7 +149,7 @@ export function loadAssetMetadata(project: IProject, asset: IAsset): (dispatch:
const assetMetadata = await assetService.getAssetMetadata(asset);
dispatch(loadAssetMetadataAction(assetMetadata));
return {...assetMetadata};
return { ...assetMetadata };
};
}
@@ -171,14 +161,77 @@ export function loadAssetMetadata(project: IProject, asset: IAsset): (dispatch:
export function saveAssetMetadata(
project: IProject,
assetMetadata: IAssetMetadata): (dispatch: Dispatch) => Promise<IAssetMetadata> {
const newAssetMetadata = {...assetMetadata, version: appInfo.version};
const newAssetMetadata = { ...assetMetadata, version: appInfo.version };
return async (dispatch: Dispatch) => {
const assetService = new AssetService(project);
const savedMetadata = await assetService.save(newAssetMetadata);
dispatch(saveAssetMetadataAction(savedMetadata));
return {...savedMetadata};
return { ...savedMetadata };
};
}
/**
* Updates a project and all asset references from oldTagName to newTagName
* @param project The project to update tags
* @param oldTagName The old tag name
* @param newTagName The new tag name
*/
export function updateProjectTag(project: IProject, oldTagName: string, newTagName: string)
: (dispatch: Dispatch, getState: () => IApplicationState) => Promise<IAssetMetadata[]> {
return async (dispatch: Dispatch, getState: () => IApplicationState) => {
// Find tags to rename
const assetService = new AssetService(project);
const assetUpdates = await assetService.renameTag(oldTagName, newTagName);
// Save updated assets
await assetUpdates.forEachAsync(async (assetMetadata) => {
await saveAssetMetadata(project, assetMetadata)(dispatch);
});
const currentProject = getState().currentProject;
const updatedProject = {
...currentProject,
tags: project.tags.map((t) => (t.name === oldTagName) ? { ...t, name: newTagName } : t),
};
// Save updated project tags
await saveProject(updatedProject)(dispatch, getState);
dispatch(updateProjectTagAction(updatedProject));
return assetUpdates;
};
}
/**
* Updates a project and all asset references from oldTagName to newTagName
* @param project The project to delete tags
* @param tagName The tag to delete
*/
export function deleteProjectTag(project: IProject, tagName)
: (dispatch: Dispatch, getState: () => IApplicationState) => Promise<IAssetMetadata[]> {
return async (dispatch: Dispatch, getState: () => IApplicationState) => {
// Find tags to rename
const assetService = new AssetService(project);
const assetUpdates = await assetService.deleteTag(tagName);
// Save updated assets
await assetUpdates.forEachAsync(async (assetMetadata) => {
await saveAssetMetadata(project, assetMetadata)(dispatch);
});
const currentProject = getState().currentProject;
const updatedProject = {
...currentProject,
tags: project.tags.filter((t) => t.name !== tagName),
};
// Save updated project tags
await saveProject(updatedProject)(dispatch, getState);
dispatch(deleteProjectTagAction(updatedProject));
return assetUpdates;
};
}
@@ -262,6 +315,20 @@ export interface IExportProjectAction extends IPayloadAction<string, IProject> {
type: ActionTypes.EXPORT_PROJECT_SUCCESS;
}
/**
* Update Project Tag action type
*/
export interface IUpdateProjectTagAction extends IPayloadAction<string, IProject> {
type: ActionTypes.UPDATE_PROJECT_TAG_SUCCESS;
}
/**
* Delete project tag action type
*/
export interface IDeleteProjectTagAction extends IPayloadAction<string, IProject> {
type: ActionTypes.DELETE_PROJECT_TAG_SUCCESS;
}
/**
* Instance of Load Project action
*/
@@ -298,3 +365,13 @@ export const saveAssetMetadataAction =
*/
export const exportProjectAction =
createPayloadAction<IExportProjectAction>(ActionTypes.EXPORT_PROJECT_SUCCESS);
/**
* Instance of Update project tag action
*/
export const updateProjectTagAction =
createPayloadAction<IUpdateProjectTagAction>(ActionTypes.UPDATE_PROJECT_TAG_SUCCESS);
/**
* Instance of Delete project tag action
*/
export const deleteProjectTagAction =
createPayloadAction<IDeleteProjectTagAction>(ActionTypes.DELETE_PROJECT_TAG_SUCCESS);
+25 -2
Ver Arquivo
@@ -1,6 +1,6 @@
import _ from "lodash";
import { reducer } from "./currentProjectReducer";
import { IProject, IAssetMetadata, AssetState } from "../../models/applicationState";
import { IProject, IAssetMetadata, AssetState, ITag } from "../../models/applicationState";
import MockFactory from "../../common/mockFactory";
import {
loadProjectAction,
@@ -50,7 +50,7 @@ describe("Current Project Reducer", () => {
expect(result).toEqual(currentProject);
});
it("Updating connection used by current project is updated in curren project", () => {
it("Updating connection used by current project is updated in current project", () => {
const currentProject = MockFactory.createTestProject("1");
const state: IProject = currentProject;
@@ -113,6 +113,29 @@ describe("Current Project Reducer", () => {
expect(result.assets[testAssets[0].id]).toEqual(assetMetadata.asset);
});
it("Appends new tags to project when saving asset contains new tags", () => {
const state: IProject = MockFactory.createTestProject("TestProject");
const testAssets = MockFactory.createTestAssets();
const expectedTag: ITag = {
name: "NEWTAG",
color: expect.any(String),
};
const assetMetadata = MockFactory.createTestAssetMetadata(
testAssets[0],
[MockFactory.createTestRegion("Region 1", [expectedTag.name])],
);
const action = saveAssetMetadataAction(assetMetadata);
const result = reducer(state, action);
expect(result).not.toBe(state);
expect(result.tags).toEqual([
...state.tags,
expectedTag,
]);
});
it("Unknown action performs a noop", () => {
const state: IProject = MockFactory.createTestProject("TestProject");
const action = anyOtherAction();
+28 -1
Ver Arquivo
@@ -1,7 +1,9 @@
import _ from "lodash";
import { ActionTypes } from "../actions/actionTypes";
import { IProject } from "../../models/applicationState";
import { IProject, ITag } from "../../models/applicationState";
import { AnyAction } from "../actions/actionCreators";
// tslint:disable-next-line:no-var-requires
const tagColors = require("../../react/components/common/tagColors.json");
/**
* Reducer for project. Actions handled:
@@ -38,6 +40,31 @@ export const reducer = (state: IProject = null, action: AnyAction): IProject =>
const updatedAssets = { ...state.assets } || {};
updatedAssets[action.payload.asset.id] = { ...action.payload.asset };
const assetTags = new Set();
action.payload.regions.forEach((region) => region.tags.forEach((tag) => assetTags.add(tag)));
const newTags: ITag[] = state.tags ? [...state.tags] : [];
let updateTags = false;
assetTags.forEach((tag) => {
if (!state.tags || state.tags.length === 0 ||
!state.tags.find((projectTag) => tag === projectTag.name)) {
newTags.push({
name: tag,
color: tagColors[newTags.length % tagColors.length],
});
updateTags = true;
}
});
if (updateTags) {
return {
...state,
tags: newTags,
assets: updatedAssets,
};
}
return {
...state,
assets: updatedAssets,
+16 -4
Ver Arquivo
@@ -1,7 +1,8 @@
import { ExportProviderFactory } from "./providers/export/exportProviderFactory";
import { TFPascalVOCExportProvider } from "./providers/export/tensorFlowPascalVOC";
import { PascalVOCExportProvider } from "./providers/export/pascalVOC";
import { TFRecordsExportProvider } from "./providers/export/tensorFlowRecords";
import { VottJsonExportProvider } from "./providers/export/vottJson";
import { CsvExportProvider } from "./providers/export/csv";
import { AssetProviderFactory } from "./providers/storage/assetProviderFactory";
import { AzureBlobStorage } from "./providers/storage/azureBlobStorage";
import { BingImageSearch } from "./providers/storage/bingImageSearch";
@@ -11,6 +12,7 @@ import registerToolbar from "./registerToolbar";
import { strings } from "./common/strings";
import { HostProcessType } from "./common/hostProcess";
import { AzureCustomVisionProvider } from "./providers/export/azureCustomVision";
import { CntkExportProvider } from "./providers/export/cntk";
/**
* Registers storage, asset and export providers, as well as all toolbar items
@@ -54,9 +56,9 @@ export default function registerProviders() {
factory: (project, options) => new VottJsonExportProvider(project, options),
});
ExportProviderFactory.register({
name: "tensorFlowPascalVOC",
displayName: strings.export.providers.tfPascalVoc.displayName,
factory: (project, options) => new TFPascalVOCExportProvider(project, options),
name: "pascalVOC",
displayName: strings.export.providers.pascalVoc.displayName,
factory: (project, options) => new PascalVOCExportProvider(project, options),
});
ExportProviderFactory.register({
name: "tensorFlowRecords",
@@ -68,6 +70,16 @@ export default function registerProviders() {
displayName: strings.export.providers.azureCV.displayName,
factory: (project, options) => new AzureCustomVisionProvider(project, options),
});
ExportProviderFactory.register({
name: "cntk",
displayName: strings.export.providers.cntk.displayName,
factory: (project, options) => new CntkExportProvider(project, options),
});
ExportProviderFactory.register({
name: "csv",
displayName: strings.export.providers.csv.displayName,
factory: (project, options) => new CsvExportProvider(project, options),
});
registerToolbar();
}
+10
Ver Arquivo
@@ -17,6 +17,7 @@ export enum ToolbarItemName {
NextAsset = "navigateNextAsset",
SaveProject = "saveProject",
ExportProject = "exportProject",
ActiveLearning = "activeLearning",
}
export enum ToolbarItemGroup {
@@ -102,6 +103,15 @@ export default function registerToolbar() {
accelerators: ["CmdOrCtrl+Delete", "CmdOrCtrl+Backspace"],
});
ToolbarItemFactory.register({
name: ToolbarItemName.ActiveLearning,
tooltip: strings.editorPage.toolbar.activeLearning,
icon: "fas fa-graduation-cap",
group: ToolbarItemGroup.Canvas,
type: ToolbarItemType.Action,
accelerators: ["CmdOrCtrl+D", "CmdOrCtrl+d"],
});
ToolbarItemFactory.register({
name: ToolbarItemName.PreviousAsset,
tooltip: strings.editorPage.toolbar.previousAsset,
+120
Ver Arquivo
@@ -0,0 +1,120 @@
import { ActiveLearningService } from "./activeLearningService";
import { IActiveLearningSettings, ModelPathType, IAssetMetadata, AssetState } from "../models/applicationState";
import MockFactory from "../common/mockFactory";
import { appInfo } from "../common/appInfo";
import { ObjectDetection } from "../providers/activeLearning/objectDetection";
describe("Active Learning Service", () => {
const objectDetectionMock = ObjectDetection as jest.Mocked<typeof ObjectDetection>;
const defaultSettings: IActiveLearningSettings = {
modelPathType: ModelPathType.Coco,
autoDetect: true,
predictTag: true,
};
let activeLearningService: ActiveLearningService = null;
const electronMock = {
remote: {
app: {
getAppPath: jest.fn(),
},
},
};
beforeAll(() => {
window["require"] = jest.fn(() => electronMock);
});
beforeEach(() => {
activeLearningService = new ActiveLearningService(defaultSettings);
objectDetectionMock.prototype.load = jest.fn(() => Promise.resolve());
objectDetectionMock.prototype.predictImage = jest.fn(() => Promise.resolve([]));
});
it("Predicts new regions to the asset metadata", async () => {
objectDetectionMock.prototype.predictImage = jest.fn(() => Promise.resolve(expectedRegions));
const expectedRegions = MockFactory.createTestRegions(2);
const canvas = MockFactory.mockCanvas()();
const asset = MockFactory.createTestAsset("TestAsset", AssetState.Visited);
const assetMetadata: IAssetMetadata = {
asset: {
...asset,
state: AssetState.Tagged,
},
regions: [],
version: appInfo.version,
};
const updatedAssetMetadata = await activeLearningService.predictRegions(canvas, assetMetadata);
expect(updatedAssetMetadata).toEqual({
asset: {
...assetMetadata.asset,
predicted: true,
},
regions: expectedRegions,
version: appInfo.version,
});
});
it("Predicts non matching regions to the asset metadata", async () => {
objectDetectionMock.prototype.predictImage = jest.fn(() => Promise.resolve(expectedRegions));
const uniqueRegion = MockFactory.createTestRegion("UniqueRegion", ["tag1", "tag2"]);
const expectedRegions = MockFactory.createTestRegions(4);
const canvas = MockFactory.mockCanvas()();
const asset = MockFactory.createTestAsset("TestAsset", AssetState.Visited);
const assetMetadata: IAssetMetadata = {
asset: {
...asset,
state: AssetState.Tagged,
},
regions: [
uniqueRegion,
expectedRegions[0],
expectedRegions[1],
],
version: appInfo.version,
};
const updatedAssetMetadata = await activeLearningService.predictRegions(canvas, assetMetadata);
expect(updatedAssetMetadata).toEqual({
asset: {
...assetMetadata.asset,
predicted: true,
},
regions: [
uniqueRegion,
...expectedRegions,
],
version: appInfo.version,
});
});
it("ensures the underlying object detection model is only loaded 1 time", async () => {
const canvas = MockFactory.mockCanvas()();
const assetMetadata: IAssetMetadata = {
asset: MockFactory.createTestAsset("TestAsset", AssetState.Visited),
regions: [],
version: appInfo.version,
};
await activeLearningService.predictRegions(canvas, assetMetadata);
await activeLearningService.predictRegions(canvas, assetMetadata);
await activeLearningService.predictRegions(canvas, assetMetadata);
await activeLearningService.predictRegions(canvas, assetMetadata);
expect(objectDetectionMock.prototype.load).toBeCalledTimes(1);
});
it("fails if constructor requirements aren't satisfied", () => {
expect(() => new ActiveLearningService(null)).toThrow();
});
it("fails if method requirements aren't satisfied", () => {
const service = new ActiveLearningService(defaultSettings);
expect(service.predictRegions(null, null)).rejects.not.toBeNull();
});
});
+104
Ver Arquivo
@@ -0,0 +1,104 @@
import { IAssetMetadata, ModelPathType, IActiveLearningSettings, AssetState } from "../models/applicationState";
import { ObjectDetection } from "../providers/activeLearning/objectDetection";
import Guard from "../common/guard";
import { isElectron } from "../common/hostProcess";
import { Env } from "../common/environment";
export class ActiveLearningService {
private objectDetection: ObjectDetection;
private modelLoaded: boolean = false;
constructor(private settings: IActiveLearningSettings) {
Guard.null(settings);
this.objectDetection = new ObjectDetection();
}
public isModelLoaded() {
return this.modelLoaded;
}
public async predictRegions(canvas: HTMLCanvasElement, assetMetadata: IAssetMetadata): Promise<IAssetMetadata> {
Guard.null(canvas);
Guard.null(assetMetadata);
// If the canvas or asset are invalid return asset metadata
if (!(canvas.width && canvas.height && assetMetadata.asset && assetMetadata.asset.size)) {
return assetMetadata;
}
await this.ensureModelLoaded();
const xRatio = assetMetadata.asset.size.width / canvas.width;
const yRatio = assetMetadata.asset.size.height / canvas.height;
const predictedRegions = await this.objectDetection.predictImage(
canvas,
this.settings.predictTag,
xRatio,
yRatio,
);
const updatedRegions = [...assetMetadata.regions];
predictedRegions.forEach((prediction) => {
const matchingRegion = updatedRegions.find((region) => {
return region.boundingBox
&& region.boundingBox.left === prediction.boundingBox.left
&& region.boundingBox.top === prediction.boundingBox.top
&& region.boundingBox.width === prediction.boundingBox.width
&& region.boundingBox.height === prediction.boundingBox.height;
});
if (updatedRegions.length === 0 || !matchingRegion) {
updatedRegions.push(prediction);
}
});
return {
...assetMetadata,
regions: updatedRegions,
asset: {
...assetMetadata.asset,
state: updatedRegions.length > 0 ? AssetState.Tagged : AssetState.Visited,
predicted: true,
},
} as IAssetMetadata;
}
public async ensureModelLoaded(): Promise<void> {
if (this.modelLoaded) {
return Promise.resolve();
}
await this.loadModel();
this.modelLoaded = true;
}
private async loadModel() {
let modelPath = "";
if (this.settings.modelPathType === ModelPathType.Coco) {
if (isElectron()) {
const appPath = this.getAppPath();
if (Env.get() !== "production") {
modelPath = appPath + "/cocoSSDModel";
} else {
modelPath = appPath + "/../../cocoSSDModel";
}
} else {
modelPath = "https://vott.blob.core.windows.net/coco-ssd-model";
}
} else if (this.settings.modelPathType === ModelPathType.File) {
if (isElectron()) {
modelPath = this.settings.modelPath;
}
} else {
modelPath = this.settings.modelUrl;
}
await this.objectDetection.load(modelPath);
}
private getAppPath = () => {
const remote = (window as any).require("electron").remote as Electron.Remote;
return remote.app.getAppPath();
}
}
+104 -1
Ver Arquivo
@@ -1,5 +1,5 @@
import { AssetService } from "./assetService";
import { AssetType, IAssetMetadata, AssetState } from "../models/applicationState";
import { AssetType, IAssetMetadata, AssetState, IAsset, IProject } from "../models/applicationState";
import MockFactory from "../common/mockFactory";
import { AssetProviderFactory, IAssetProvider } from "../providers/storage/assetProviderFactory";
import { StorageProviderFactory } from "../providers/storage/storageProviderFactory";
@@ -7,6 +7,8 @@ import { constants } from "../common/constants";
import { TFRecordsBuilder, FeatureType } from "../providers/export/tensorFlowRecords/tensorFlowBuilder";
import HtmlFileReader from "../common/htmlFileReader";
import { encodeFileURI } from "../common/utils";
import _ from "lodash";
import registerMixins from "../registerMixins";
describe("Asset Service", () => {
describe("Static Methods", () => {
@@ -323,4 +325,105 @@ describe("Asset Service", () => {
expect(Math.floor(result.regions[1].points[1].y)).toEqual(800);
});
});
describe("Tag Update functions", () => {
function populateProjectAssets(project?: IProject, assetCount = 10) {
if (!project) {
project = MockFactory.createTestProject();
}
const assets = MockFactory.createTestAssets(assetCount);
assets.forEach((asset) => {
asset.state = AssetState.Tagged;
});
project.assets = _.keyBy(assets, (asset) => asset.id);
return project;
}
beforeAll(() => {
registerMixins();
});
it("Deletes tag from assets", async () => {
const tag1 = "tag1";
const tag2 = "tag2";
const region = MockFactory.createTestRegion(undefined, [tag1, tag2]);
const asset: IAsset = {
...MockFactory.createTestAsset("1"),
state: AssetState.Tagged,
};
const assetMetadata = MockFactory.createTestAssetMetadata(asset, [region]);
AssetService.prototype.getAssetMetadata = jest.fn((asset: IAsset) => Promise.resolve(assetMetadata));
const expectedAssetMetadata: IAssetMetadata = {
...MockFactory.createTestAssetMetadata(
asset,
[
{
...region,
tags: [tag2],
},
],
),
};
const project = populateProjectAssets();
const assetService = new AssetService(project);
const assetUpdates = await assetService.deleteTag(tag1);
expect(assetUpdates).toHaveLength(1);
expect(assetUpdates[0]).toEqual(expectedAssetMetadata);
});
it("Deletes empty regions after deleting only tag from region", async () => {
const tag1 = "tag1";
const region = MockFactory.createTestRegion(undefined, [tag1]);
const asset: IAsset = {
...MockFactory.createTestAsset("1"),
state: AssetState.Tagged,
};
const assetMetadata = MockFactory.createTestAssetMetadata(asset, [region]);
AssetService.prototype.getAssetMetadata = jest.fn((asset: IAsset) => Promise.resolve(assetMetadata));
const expectedAssetMetadata: IAssetMetadata = MockFactory.createTestAssetMetadata(asset, []);
const project = populateProjectAssets();
const assetService = new AssetService(project);
const assetUpdates = await assetService.deleteTag(tag1);
expect(assetUpdates).toHaveLength(1);
expect(assetUpdates[0]).toEqual(expectedAssetMetadata);
});
it("Updates renamed tag within all assets", async () => {
const tag1 = "tag1";
const newTag = "tag2";
const region = MockFactory.createTestRegion(undefined, [tag1]);
const asset: IAsset = {
...MockFactory.createTestAsset("1"),
state: AssetState.Tagged,
};
const assetMetadata = MockFactory.createTestAssetMetadata(asset, [region]);
AssetService.prototype.getAssetMetadata = jest.fn((asset: IAsset) => Promise.resolve(assetMetadata));
const expectedAssetMetadata: IAssetMetadata = {
...MockFactory.createTestAssetMetadata(
asset,
[
{
...region,
tags: [newTag],
},
],
),
};
const project = populateProjectAssets();
const assetService = new AssetService(project);
const assetUpdates = await assetService.renameTag(tag1, newTag);
expect(assetUpdates).toHaveLength(1);
expect(assetUpdates[0]).toEqual(expectedAssetMetadata);
});
});
});
+64 -1
Ver Arquivo
@@ -204,10 +204,73 @@ export class AssetService {
}
}
/**
* Delete a tag from asset metadata files
* @param tagName Name of tag to delete
*/
public async deleteTag(tagName: string): Promise<IAssetMetadata[]> {
const transformer = (tags) => tags.filter((t) => t !== tagName);
return await this.getUpdatedAssets(tagName, transformer);
}
/**
* Rename a tag within asset metadata files
* @param tagName Name of tag to rename
*/
public async renameTag(tagName: string, newTagName: string): Promise<IAssetMetadata[]> {
const transformer = (tags) => tags.map((t) => (t === tagName) ? newTagName : t);
return await this.getUpdatedAssets(tagName, transformer);
}
/**
* Update tags within asset metadata files
* @param tagName Name of tag to update within project
* @param transformer Function that accepts array of tags from a region and returns a modified array of tags
*/
private async getUpdatedAssets(tagName: string, transformer: (tags: string[]) => string[])
: Promise<IAssetMetadata[]> {
// Loop over assets and update if necessary
const updates = await _.values(this.project.assets).mapAsync(async (asset) => {
const assetMetadata = await this.getAssetMetadata(asset);
const isUpdated = this.updateTagInAssetMetadata(assetMetadata, tagName, transformer);
return isUpdated ? assetMetadata : null;
});
return updates.filter((assetMetadata) => !!assetMetadata);
}
/**
* Update tag within asset metadata object
* @param assetMetadata Asset metadata to update
* @param tagName Name of tag being updated
* @param transformer Function that accepts array of tags from a region and returns a modified array of tags
* @returns Modified asset metadata object or null if object does not need to be modified
*/
private updateTagInAssetMetadata(
assetMetadata: IAssetMetadata,
tagName: string,
transformer: (tags: string[]) => string[]): boolean {
let foundTag = false;
for (const region of assetMetadata.regions) {
if (region.tags.find((t) => t === tagName)) {
foundTag = true;
region.tags = transformer(region.tags);
}
}
if (foundTag) {
assetMetadata.regions = assetMetadata.regions.filter((region) => region.tags.length > 0);
assetMetadata.asset.state = (assetMetadata.regions.length) ? AssetState.Tagged : AssetState.Visited;
return true;
}
return false;
}
private async getRegionsFromTFRecord(asset: IAsset): Promise<IRegion[]> {
const objectArray = await this.getTFRecordMetadata(asset);
const regions: IRegion[] = [];
const tags: string[] = [];
// Add Regions from TFRecord in Regions
for (let index = 0; index < objectArray.textArray.length; index++) {
+2 -1
Ver Arquivo
@@ -2,7 +2,7 @@ import shortid from "shortid";
import {
IProject, ITag, IConnection, AppError, ErrorCode,
IAssetMetadata, IRegion, RegionType, AssetState, IFileInfo,
IAsset, AssetType,
IAsset, AssetType, ModelPathType,
} from "../models/applicationState";
import { IV1Project, IV1Region } from "../models/v1Models";
import packageJson from "../../package.json";
@@ -66,6 +66,7 @@ export default class ImportService implements IImportService {
videoSettings: {
frameExtractionRate: originalProject.framerate ? Number(originalProject.framerate) : 15,
},
activeLearningSettings: null,
autoSave: true,
};
}
+66 -2
Ver Arquivo
@@ -2,11 +2,17 @@ import _ from "lodash";
import ProjectService, { IProjectService } from "./projectService";
import MockFactory from "../common/mockFactory";
import { StorageProviderFactory } from "../providers/storage/storageProviderFactory";
import { IProject, IExportFormat, ISecurityToken, AssetState } from "../models/applicationState";
import {
IProject, IExportFormat, ISecurityToken,
AssetState, IActiveLearningSettings, ModelPathType,
} from "../models/applicationState";
import { constants } from "../common/constants";
import { ExportProviderFactory } from "../providers/export/exportProviderFactory";
import { generateKey } from "../common/crypto";
import { encryptProject } from "../common/utils";
import { encryptProject, decryptProject } from "../common/utils";
import { ExportAssetState } from "../providers/export/exportProvider";
import { IVottJsonExportProviderOptions } from "../providers/export/vottJson";
import { IPascalVOCExportProviderOptions } from "../providers/export/pascalVOC";
describe("Project Service", () => {
let projectSerivce: IProjectService = null;
@@ -76,6 +82,45 @@ describe("Project Service", () => {
expect.any(String));
});
it("sets default export settings when not defined", async () => {
testProject.exportFormat = null;
const result = await projectSerivce.save(testProject, securityToken);
const vottJsonExportProviderOptions: IVottJsonExportProviderOptions = {
assetState: ExportAssetState.Visited,
includeImages: true,
};
const expectedExportFormat: IExportFormat = {
providerType: "vottJson",
providerOptions: vottJsonExportProviderOptions,
};
const decryptedProject = decryptProject(result, securityToken);
expect(decryptedProject.exportFormat).toEqual(expectedExportFormat);
});
it("sets default active learning setting when not defined", async () => {
testProject.activeLearningSettings = null;
const result = await projectSerivce.save(testProject, securityToken);
const activeLearningSettings: IActiveLearningSettings = {
autoDetect: false,
predictTag: true,
modelPathType: ModelPathType.Coco,
};
expect(result.activeLearningSettings).toEqual(activeLearningSettings);
});
it("initializes tags to empty array if not defined", async () => {
testProject.tags = null;
const result = await projectSerivce.save(testProject, securityToken);
expect(result.tags).toEqual([]);
});
it("Save calls configured export provider save when defined", async () => {
testProject.exportFormat = {
providerType: "azureCustomVision",
@@ -157,4 +202,23 @@ describe("Project Service", () => {
await projectSerivce.delete(testProject);
expect(storageProviderMock.deleteFile.mock.calls).toHaveLength(assets.length + 1);
});
it("Updates export settings to v2.1 supported values", async () => {
testProject = MockFactory.createTestProject("TestProject");
testProject.version = "2.0.0";
testProject.exportFormat = {
providerType: "tensorFlowPascalVOC",
providerOptions: {
assetState: ExportAssetState.All,
exportUnassigned: true,
testTrainSplit: 80,
} as IPascalVOCExportProviderOptions,
};
const encryptedProject = encryptProject(testProject, securityToken);
const decryptedProject = await projectSerivce.load(encryptedProject, securityToken);
expect(decryptedProject.exportFormat.providerType).toEqual("pascalVOC");
expect(decryptedProject.exportFormat.providerOptions).toEqual(testProject.exportFormat.providerOptions);
});
});
+69 -2
Ver Arquivo
@@ -1,12 +1,17 @@
import _ from "lodash";
import shortid from "shortid";
import { StorageProviderFactory } from "../providers/storage/storageProviderFactory";
import { IProject, ISecurityToken, AppError, ErrorCode, AssetState } from "../models/applicationState";
import {
IProject, ISecurityToken, AppError,
ErrorCode, ModelPathType, IActiveLearningSettings,
} from "../models/applicationState";
import Guard from "../common/guard";
import { constants } from "../common/constants";
import { ExportProviderFactory } from "../providers/export/exportProviderFactory";
import { decryptProject, encryptProject } from "../common/utils";
import packageJson from "../../package.json";
import { ExportAssetState } from "../providers/export/exportProvider";
import { IExportFormat } from "vott-react";
/**
* Functions required for a project service
@@ -20,6 +25,20 @@ export interface IProjectService {
isDuplicate(project: IProject, projectList: IProject[]): boolean;
}
const defaultActiveLearningSettings: IActiveLearningSettings = {
autoDetect: false,
predictTag: true,
modelPathType: ModelPathType.Coco,
};
const defaultExportOptions: IExportFormat = {
providerType: "vottJson",
providerOptions: {
assetState: ExportAssetState.Visited,
includeImages: true,
},
};
/**
* @name - Project Service
* @description - Functions for dealing with projects
@@ -35,7 +54,25 @@ export default class ProjectService implements IProjectService {
try {
const loadedProject = decryptProject(project, securityToken);
return Promise.resolve(loadedProject);
// Ensure tags is always initialized to an array
if (!loadedProject.tags) {
loadedProject.tags = [];
}
// Initialize active learning settings if they don't exist
if (!loadedProject.activeLearningSettings) {
loadedProject.activeLearningSettings = defaultActiveLearningSettings;
}
// Initialize export settings if they don't exist
if (!loadedProject.exportFormat) {
loadedProject.exportFormat = defaultExportOptions;
}
this.ensureBackwardsCompatibility(loadedProject);
return Promise.resolve({ ...loadedProject });
} catch (e) {
const error = new AppError(ErrorCode.ProjectInvalidSecurityToken, "Error decrypting project settings");
return Promise.reject(error);
@@ -54,6 +91,21 @@ export default class ProjectService implements IProjectService {
project.id = shortid.generate();
}
// Ensure tags is always initialized to an array
if (!project.tags) {
project.tags = [];
}
// Initialize active learning settings if they don't exist
if (!project.activeLearningSettings) {
project.activeLearningSettings = defaultActiveLearningSettings;
}
// Initialize export settings if they don't exist
if (!project.exportFormat) {
project.exportFormat = defaultExportOptions;
}
project.version = packageJson.version;
const storageProvider = StorageProviderFactory.createFromConnection(project.targetConnection);
@@ -113,4 +165,19 @@ export default class ProjectService implements IProjectService {
project.exportFormat.providerOptions = await exportProvider.save(project.exportFormat);
}
/**
* Ensures backwards compatibility with project
* @param project The project to update
*/
private ensureBackwardsCompatibility(project: IProject) {
const projectVersion = project.version.toLowerCase();
if (projectVersion.startsWith("2.0.0")) {
// Required for backwards compatibility with v2.0.0 release
if (project.exportFormat.providerType === "tensorFlowPascalVOC") {
project.exportFormat.providerType = "pascalVOC";
}
}
}
}
+26 -22
Ver Arquivo
@@ -1,25 +1,29 @@
{
"compilerOptions": {
"target": "es6",
"allowJs": false,
"skipLibCheck": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"experimentalDecorators": true,
"lib": [
"es2015",
"dom"
"compilerOptions": {
"target": "es6",
"allowJs": false,
"skipLibCheck": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"experimentalDecorators": true,
"lib": [
"es2015",
"dom"
],
"typeRoots": [
"./typings",
"./node_modules/@types"
],
},
"include": [
"src"
]
},
"include": [
"src"
]
}
-5
Ver Arquivo
@@ -1,5 +0,0 @@
{
"globalDevDependencies": {
"react-jsonschema-form": "registry:dt/react-jsonschema-form#0.43.0+20170226152137"
}
}
-33
Ver Arquivo
@@ -1,33 +0,0 @@
// Generated by typings
// Source: https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/46d11bc6ab4a0a9b711b403662dceefb67ebb575/react-jsonschema-form/index.d.ts
declare module "react-jsonschema-form" {
import * as React from "react";
export interface FormProps {
schema: {};
uiSchema?: {};
formData?: any;
widgets?: {};
fields?: {};
noValidate?: boolean;
noHtml5Validate?: boolean;
showErrorList?: boolean;
validate?: (formData: any, errors: any) => any;
onChange?: (e: IChangeEvent) => any;
onError?: (e: any) => any;
onSubmit?: (e: any) => any;
liveValidate?: boolean;
safeRenderCompletion?: boolean;
}
export interface IChangeEvent {
edit: boolean;
formData: any;
errors: any[];
errorSchema: any;
idSchema: any;
status: string;
}
export default class Form extends React.Component<FormProps, any> {}
}

Alguns arquivos não foram exibidos porque demasiados arquivos foram alterados neste diff Mostrar Mais