diff --git a/public/js/spinner.js b/public/js/spinner.js new file mode 100644 index 00000000..de517bbb --- /dev/null +++ b/public/js/spinner.js @@ -0,0 +1,229 @@ +/** + * Njobvu-AI Reusable Loading Spinner Helper + * Provides inline button spinners, form loading states, overlay spinners, and fetch wrappers. + */ +(function(window) { + 'use strict'; + + // Inject CSS styles if not present + function injectStyles() { + if (typeof document === 'undefined') return; + if (document.getElementById('njobvu-spinner-styles')) return; + const style = document.createElement('style'); + style.id = 'njobvu-spinner-styles'; + style.textContent = ` + .njobvu-spinner-inline { + display: inline-block; + width: 1em; + height: 1em; + vertical-align: -0.125em; + border: 0.15em solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: njobvu-spinner-spin .75s linear infinite; + margin-right: 0.5rem; + } + .njobvu-spinner-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 9999; + color: #ffffff; + font-family: inherit; + } + .njobvu-spinner-card { + background: #1e293b; + color: #f8fafc; + padding: 1.5rem 2rem; + border-radius: 8px; + box-shadow: 0 10px 25px rgba(0,0,0,0.3); + display: flex; + flex-direction: column; + align-items: center; + max-width: 90%; + } + .njobvu-spinner-lg { + width: 2.5rem; + height: 2.5rem; + border-width: 0.25em; + margin-right: 0; + margin-bottom: 1rem; + } + @keyframes njobvu-spinner-spin { + 100% { transform: rotate(360deg); } + } + `; + document.head.appendChild(style); + } + + if (typeof document !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', injectStyles); + } else { + injectStyles(); + } + } + + const LoadingSpinner = { + /** + * Shows spinner on a target element (button, submit input, or container) or full screen overlay if target is null. + * @param {Element|string} [target] - Element or selector to attach spinner to + * @param {Object|string} [options] - Options or text message + */ + show: function(target, options) { + injectStyles(); + let text = typeof options === 'string' ? options : (options && options.text !== undefined ? options.text : 'Processing...'); + + if (!target) { + return this.showOverlay(text); + } + + const el = typeof target === 'string' && typeof document !== 'undefined' ? document.querySelector(target) : target; + if (!el) { + return this.showOverlay(text); + } + + // If target is a form, target its submit button(s) + if (el.tagName === 'FORM') { + const submitBtns = el.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type])'); + submitBtns.forEach(btn => this.show(btn, options)); + return; + } + + // Save original state if not already saved + if (!el.dataset.njobvuOriginalHtml) { + el.dataset.njobvuOriginalHtml = el.innerHTML || el.value || ''; + } + if (el.dataset.njobvuOriginalDisabled === undefined) { + el.dataset.njobvuOriginalDisabled = el.disabled ? 'true' : 'false'; + } + + el.disabled = true; + el.classList.add('njobvu-btn-loading'); + + const spinnerHtml = ``; + + if (el.tagName === 'INPUT' && (el.type === 'submit' || el.type === 'button')) { + el.value = text ? text : 'Processing...'; + } else { + el.innerHTML = spinnerHtml + (text ? `${text}` : el.dataset.njobvuOriginalHtml); + } + }, + + /** + * Restores target element to its original state + * @param {Element|string} [target] + */ + hide: function(target) { + if (!target) { + this.hideOverlay(); + return; + } + + const el = typeof target === 'string' && typeof document !== 'undefined' ? document.querySelector(target) : target; + if (!el) { + this.hideOverlay(); + return; + } + + if (el.tagName === 'FORM') { + const submitBtns = el.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type])'); + submitBtns.forEach(btn => this.hide(btn)); + return; + } + + if (el.dataset && el.dataset.njobvuOriginalHtml !== undefined) { + if (el.tagName === 'INPUT' && (el.type === 'submit' || el.type === 'button')) { + el.value = el.dataset.njobvuOriginalHtml; + } else { + el.innerHTML = el.dataset.njobvuOriginalHtml; + } + delete el.dataset.njobvuOriginalHtml; + } + + if (el.dataset && el.dataset.njobvuOriginalDisabled !== undefined) { + el.disabled = el.dataset.njobvuOriginalDisabled === 'true'; + delete el.dataset.njobvuOriginalDisabled; + } else { + el.disabled = false; + } + + el.classList.remove('njobvu-btn-loading'); + }, + + /** + * Shows full-screen overlay spinner with message + */ + showOverlay: function(message) { + if (typeof document === 'undefined') return; + injectStyles(); + let overlay = document.getElementById('njobvu-overlay-spinner'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'njobvu-overlay-spinner'; + overlay.className = 'njobvu-spinner-overlay'; + overlay.innerHTML = ` +
+ +
${message || 'Loading, please wait...'}
+
+ `; + document.body.appendChild(overlay); + } else { + const textEl = document.getElementById('njobvu-overlay-text'); + if (textEl) textEl.textContent = message || 'Loading, please wait...'; + overlay.style.display = 'flex'; + } + }, + + /** + * Updates text of existing overlay spinner + */ + updateOverlayText: function(message) { + if (typeof document === 'undefined') return; + const textEl = document.getElementById('njobvu-overlay-text'); + if (textEl) textEl.textContent = message; + }, + + /** + * Hides full-screen overlay spinner + */ + hideOverlay: function() { + if (typeof document === 'undefined') return; + const overlay = document.getElementById('njobvu-overlay-spinner'); + if (overlay) { + overlay.style.display = 'none'; + } + }, + + /** + * Wraps a fetch promise or async function call with show/hide spinner handlers + */ + wrapFetch: async function(fetchPromiseOrFn, target, options) { + this.show(target, options); + try { + const promise = typeof fetchPromiseOrFn === 'function' ? fetchPromiseOrFn() : fetchPromiseOrFn; + const result = await promise; + return result; + } finally { + this.hide(target); + } + } + }; + + if (typeof window !== 'undefined') { + window.LoadingSpinner = LoadingSpinner; + window.showSpinner = function(target, options) { LoadingSpinner.show(target, options); }; + window.hideSpinner = function(target) { LoadingSpinner.hide(target); }; + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = LoadingSpinner; + } +})(typeof window !== 'undefined' ? window : this); diff --git a/queries/projects/projects.js b/queries/projects/projects.js index 1c5851ef..ef7bcf11 100644 --- a/queries/projects/projects.js +++ b/queries/projects/projects.js @@ -204,6 +204,20 @@ module.exports = { await db.run( "CREATE TABLE IF NOT EXISTS Images (IName VARCHAR NOT NULL PRIMARY KEY, reviewImage INTEGER NOT NULL DEFAULT 0, validateImage INTEGER NOT NULL DEFAULT 0)", ); + try { + await db.run( + "ALTER TABLE Images ADD COLUMN reviewImage INTEGER NOT NULL DEFAULT 0", + ); + } catch (e) { + // Column already exists + } + try { + await db.run( + "ALTER TABLE Images ADD COLUMN validateImage INTEGER NOT NULL DEFAULT 0", + ); + } catch (e) { + // Column already exists + } await db.run( "CREATE TABLE IF NOT EXISTS Labels (LID INTEGER PRIMARY KEY, CName VARCHAR NOT NULL, X VARCHAR NOT NULL, Y VARCHAR NOT NULL, W INTEGER NOT NULL, H INTEGER NOT NULL, IName VARCHAR NOT NULL, FOREIGN KEY(CName) REFERENCES Classes(CName), FOREIGN KEY(IName) REFERENCES Images(IName))", ); diff --git a/routes/api.js b/routes/api.js index 111e6915..72f9bd42 100755 --- a/routes/api.js +++ b/routes/api.js @@ -59,6 +59,7 @@ const importDataset = require("./projects/importDataset"); const importYolo = require("./projects/importYolo"); const importKwCoco = require("./projects/importKwCoco"); const importIfcb = require("./projects/importIfcb"); +const mapKwCocoCsv = require("./projects/mapKwCocoCsv"); const mergeLocal = require("./projects/mergeLocal"); const removeAccess = require("./projects/removeAccess"); const transferAdmin = require("./projects/transferAdmin"); @@ -169,6 +170,8 @@ api.post("/import", importProject); api.post("/api/projects/import-dataset", importDataset); api.post("/api/projects/import-yolo", importYolo); api.post("/api/projects/import-kwcoco", importKwCoco); +api.post("/api/projects/map-kwcoco-csv", mapKwCocoCsv); +api.post("/mapKwCocoCsv", mapKwCocoCsv); api.post("/api/projects/import-ifcb", importIfcb); api.post("/mergeLocal", mergeLocal); api.post("/removeAccess", removeAccess); diff --git a/routes/api/projectsFilter.js b/routes/api/projectsFilter.js index 67c51a15..749e4a1c 100644 --- a/routes/api/projectsFilter.js +++ b/routes/api/projectsFilter.js @@ -207,18 +207,22 @@ async function getFilteredImagesApi(req, res) { const pdb = new sqlite3.Database(db_path, (err) => { if (err) return reject(err); - const query = ` - SELECT Images.IName, Images.reviewImage, Images.validateImage, COUNT(Labels.LID) AS numLabels - FROM Images - LEFT JOIN Labels ON Images.IName = Labels.IName - GROUP BY Images.IName - `; + pdb.run("ALTER TABLE Images ADD COLUMN reviewImage INTEGER NOT NULL DEFAULT 0", () => { + pdb.run("ALTER TABLE Images ADD COLUMN validateImage INTEGER NOT NULL DEFAULT 0", () => { + const query = ` + SELECT Images.IName, Images.reviewImage, Images.validateImage, COUNT(Labels.LID) AS numLabels + FROM Images + LEFT JOIN Labels ON Images.IName = Labels.IName + GROUP BY Images.IName + `; - pdb.all(query, [], (err, rows) => { - pdb.close(); - if (err) return reject(err); - images = rows || []; - resolve(); + pdb.all(query, [], (err, rows) => { + pdb.close(); + if (err) return reject(err); + images = rows || []; + resolve(); + }); + }); }); }); }); diff --git a/routes/pages/getProjectPage.js b/routes/pages/getProjectPage.js index 6ce26242..2b77decb 100644 --- a/routes/pages/getProjectPage.js +++ b/routes/pages/getProjectPage.js @@ -60,6 +60,15 @@ async function getProjectPage(req, res) { global.logger.info("Connected to pdb.") }); + pdb.runAsync = function(sql, params) { + var that = this; + return new Promise(function(resolve) { + that.run(sql, params || [], function() { + resolve(); + }); + }); + }; + pdb.allAsync = function(sql) { var that = this; return new Promise(function(resolve, reject) { @@ -75,6 +84,10 @@ async function getProjectPage(req, res) { }); }; + // Ensure schema compatibility for legacy project databases + await pdb.runAsync("ALTER TABLE Images ADD COLUMN reviewImage INTEGER NOT NULL DEFAULT 0"); + await pdb.runAsync("ALTER TABLE Images ADD COLUMN validateImage INTEGER NOT NULL DEFAULT 0"); + var rawImages = await pdb.allAsync( "SELECT Images.IName, Images.reviewImage, Images.validateImage, COUNT(Labels.LID) AS numLabels " + "FROM Images LEFT JOIN Labels ON Images.IName = Labels.IName " + diff --git a/routes/pages/getYoloXTrainingSettingsPage.js b/routes/pages/getYoloXTrainingSettingsPage.js index 79bfe3fd..5a38f5a4 100644 --- a/routes/pages/getYoloXTrainingSettingsPage.js +++ b/routes/pages/getYoloXTrainingSettingsPage.js @@ -78,12 +78,12 @@ async function getYoloXInferencePage(req, res) { fs.mkdirSync(log_path); fs.mkdirSync(python_path); fs.mkdirSync(weights_path); - fs.writeFile(python_path_file, "", function (err) { + fs.writeFile(python_path_file, "", function(err) { if (err) { global.logger.error(err); } }); - fs.writeFile(yolovx_path_file, "", function (err) { + fs.writeFile(yolovx_path_file, "", function(err) { if (err) { global.logger.error(err); } @@ -91,7 +91,7 @@ async function getYoloXInferencePage(req, res) { } else if (!fs.existsSync(weights_path)) { fs.mkdirSync(weights_path); } else if (!fs.existsSync(yolovx_path_file)) { - fs.writeFile(yolovx_path_file, "", function (err) { + fs.writeFile(yolovx_path_file, "", function(err) { if (err) { global.logger.error(err); } @@ -107,10 +107,10 @@ async function getYoloXInferencePage(req, res) { }); // create async database object functions - tdb.getAsync = function (sql) { + tdb.getAsync = function(sql) { var that = this; - return new Promise(function (resolve, reject) { - that.get(sql, function (err, row) { + return new Promise(function(resolve, reject) { + that.get(sql, function(err, row) { if (err) { global.logger.error("runAsync ERROR!", err) reject(err); @@ -120,10 +120,10 @@ async function getYoloXInferencePage(req, res) { global.logger.error(err); }); }; - tdb.allAsync = function (sql) { + tdb.allAsync = function(sql) { var that = this; - return new Promise(function (resolve, reject) { - that.all(sql, function (err, row) { + return new Promise(function(resolve, reject) { + that.all(sql, function(err, row) { if (err) { global.logger.error("runAsync ERROR!", err) reject(err); @@ -148,7 +148,7 @@ async function getYoloXInferencePage(req, res) { try { var countsResult = await queries.project.getClassLabelCounts(project_path); if (countsResult && countsResult.rows) { - countsResult.rows.forEach(function (row) { + countsResult.rows.forEach(function(row) { classLabelCounts[row.CName] = row.labelCount; }); } @@ -161,7 +161,7 @@ async function getYoloXInferencePage(req, res) { try { var imageCountsResult = await queries.project.getClassImageCounts(project_path); if (imageCountsResult && imageCountsResult.rows) { - imageCountsResult.rows.forEach(function (row) { + imageCountsResult.rows.forEach(function(row) { classImageCounts[row.CName] = row.imageCount; }); } @@ -169,7 +169,7 @@ async function getYoloXInferencePage(req, res) { global.logger.error(err); } - results2 = results2.map(function (cls) { + results2 = results2.map(function(cls) { return Object.assign({}, cls, { labelCount: classLabelCounts[cls.CName] || 0, imageCount: classImageCounts[cls.CName] || 0, @@ -224,13 +224,13 @@ async function getYoloXInferencePage(req, res) { weight = []; run_path = `${log_path}${runs[i]}/`; run_paths.push(run_path); - + // get all files for each run (including subdirectories) var runFiles = []; - + // Read main directory files logs = await readdirAsync(`${run_path}`); - + // Add files from main directory for (var j = 0; j < logs.length; j++) { var filePath = run_path + logs[j]; @@ -249,7 +249,7 @@ async function getYoloXInferencePage(req, res) { global.logger.debug("Error reading file stats:", err); } } - + // Read subdirectories for additional files (like plots, results, etc.) // We need to go deeper since YOLO puts training files in train/ subdirectory for (var j = 0; j < logs.length; j++) { @@ -301,9 +301,9 @@ async function getYoloXInferencePage(req, res) { global.logger.debug("Error checking directory:", err); } } - + all_run_files.push(runFiles); - + // get index of log file log_idx = logs.indexOf(`${runs[i]}.log`); // get log file for each run @@ -369,7 +369,7 @@ async function getYoloXInferencePage(req, res) { } // close the database - tdb.close(function (err) { + tdb.close(function(err) { if (err) { global.logger.error(err); } else { diff --git a/routes/projects/mapKwCocoCsv.js b/routes/projects/mapKwCocoCsv.js new file mode 100644 index 00000000..6f09b827 --- /dev/null +++ b/routes/projects/mapKwCocoCsv.js @@ -0,0 +1,127 @@ +const path = require('path'); +const fs = require('fs'); +const parseKwCocoCsv = require('../../utils/parseKwCocoCsv'); +const parseKwCocoJson = require('../../utils/parseKwCocoJson'); +const queries = require('../../queries/queries'); +const { Client } = require('../../queries/client'); + +async function mapKwCocoCsv(req, res) { + try { + const projectName = req.body.PName || req.body.project_name || req.body.projectName; + const admin = req.body.Admin || req.cookies?.Username || 'admin'; + + if (!projectName) { + return res.status(400).json({ success: false, message: 'Project name is required.' }); + } + + if (!req.files || Object.keys(req.files).length === 0) { + return res.status(400).json({ success: false, message: 'No annotation file was uploaded.' }); + } + + const uploadedFile = req.files.kwcoco_csv || req.files.kwcoco_json || req.files.csv_file + || req.files.json_file || req.files.upload_csv || req.files.upload_json || Object.values(req.files)[0]; + if (!uploadedFile) { + return res.status(400).json({ success: false, message: 'Invalid file upload payload.' }); + } + + const fileContent = uploadedFile.data ? uploadedFile.data.toString('utf8') : fs.readFileSync(uploadedFile.tempFilePath, 'utf8'); + + const ext = path.extname(uploadedFile.name || '').toLowerCase(); + const trimmedContent = fileContent.trim(); + const isJson = ext === '.json' || (ext !== '.csv' && (trimmedContent.startsWith('{') || trimmedContent.startsWith('['))); + + const parsedAnnotations = isJson ? parseKwCocoJson(fileContent) : parseKwCocoCsv(fileContent); + if (parsedAnnotations.length === 0) { + return res.status(400).json({ success: false, message: 'No valid KW COCO annotations found in file.' }); + } + + const mainPath = path.join(__dirname, '..', '..', 'public', 'projects'); + const projectPath = path.join(mainPath, `${admin}-${projectName}`); + + if (!fs.existsSync(projectPath)) { + return res.status(404).json({ success: false, message: `Project path not found: ${admin}-${projectName}` }); + } + + const dbPath = path.join(projectPath, `${projectName}.db`); + if (!global.projectDbClients[projectPath]) { + global.projectDbClients[projectPath] = new Client(dbPath); + const client = global.projectDbClients[projectPath]; + if (typeof client.open === 'function') { + client.open(); + } + } + + // Migrate DB if needed + await queries.project.migrateProjectDb(projectPath); + + // 1. Ensure all referenced classes exist in Classes table + const existingClassResult = await queries.project.getAllClasses(projectPath); + const existingClassRows = existingClassResult?.rows || []; + const existingClassSet = new Set(existingClassRows.map(c => c.CName)); + + const uniqueClasses = new Set(parsedAnnotations.map(a => a.className)); + let classesAdded = 0; + + for (const cname of uniqueClasses) { + if (!existingClassSet.has(cname)) { + await queries.project.createClass(projectPath, cname); + existingClassSet.add(cname); + classesAdded++; + } + } + + // 2. Ensure referenced images exist in Images table + const existingImageResult = await queries.project.getAllImages(projectPath); + const existingImageRows = existingImageResult?.rows || []; + const existingImageSet = new Set(existingImageRows.map(i => i.IName)); + + const uniqueImages = new Set(parsedAnnotations.map(a => a.filename)); + let imagesRegistered = 0; + + for (const iname of uniqueImages) { + if (!existingImageSet.has(iname)) { + await queries.project.sql(projectPath, "INSERT OR IGNORE INTO Images (IName, reviewImage, validateImage) VALUES (?, 0, 0)", [iname]); + existingImageSet.add(iname); + imagesRegistered++; + } + } + + // 3. Get current max LID in Labels table + const maxLidResult = await queries.project.getMaxLabelId(projectPath); + const maxLidRows = maxLidResult?.rows || []; + let nextLid = 1; + if (maxLidRows.length > 0 && maxLidRows[0].LID) { + nextLid = maxLidRows[0].LID + 1; + } + + // 4. Insert labels + let labelsInserted = 0; + for (const ann of parsedAnnotations) { + await queries.project.createLabel( + projectPath, + nextLid++, + ann.className, + ann.x, + ann.y, + ann.w, + ann.h, + ann.filename + ); + labelsInserted++; + } + + return res.json({ + success: true, + message: `Successfully mapped ${labelsInserted} KW COCO annotations.`, + labelsInserted, + classesAdded, + imagesRegistered + }); + + } catch (err) { + console.error('Error mapping KW COCO CSV annotations:', err); + return res.status(500).json({ success: false, message: err.message || 'Internal server error mapping KW COCO CSV.' }); + } +} + +module.exports = mapKwCocoCsv; diff --git a/tests/integration/legacyDbMigration.test.js b/tests/integration/legacyDbMigration.test.js new file mode 100644 index 00000000..188ae6cf --- /dev/null +++ b/tests/integration/legacyDbMigration.test.js @@ -0,0 +1,74 @@ +const path = require('path'); +const fs = require('fs'); +const sqlite3 = require('sqlite3').verbose(); +const queries = require('../../queries/queries'); + +describe('Legacy Database Migration for Images validateImage / reviewImage columns', () => { + let tmpDir; + let dbPath; + + beforeEach(() => { + global.projectDbClients = {}; + tmpDir = path.join(__dirname, 'tmp_legacy_db_' + Date.now()); + if (!fs.existsSync(tmpDir)) { + fs.mkdirSync(tmpDir, { recursive: true }); + } + dbPath = path.join(tmpDir, 'test_legacy.db'); + global.projectDbClients[dbPath] = new sqlite3.Database(dbPath); + }); + + afterEach(() => { + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + delete global.projectDbClients[dbPath]; + }); + + test('migrateProjectDb adds missing reviewImage and validateImage columns to existing Images table', async () => { + const db = global.projectDbClients[dbPath]; + db.run('CREATE TABLE Images (IName VARCHAR NOT NULL PRIMARY KEY)'); + db.run("INSERT INTO Images (IName) VALUES ('test_image.jpg')"); + + // Run migrateProjectDb + await queries.project.migrateProjectDb(dbPath); + + // Verify query succeeds + await new Promise((resolve, reject) => { + const pdb = global.projectDbClients[dbPath]; + pdb.all( + 'SELECT Images.IName, Images.reviewImage, Images.validateImage FROM Images', + [], + (err, rows) => { + if (err) return reject(err); + expect(rows).toBeDefined(); + resolve(); + } + ); + }); + }); + + test('projectsFilter route handler query auto-migrates missing columns', async () => { + const db = global.projectDbClients[dbPath]; + db.run('CREATE TABLE Images (IName VARCHAR NOT NULL PRIMARY KEY)'); + + // Simulate reading legacy db using the same logic as getProjectPage / projectsFilter + await new Promise((resolve, reject) => { + const pdb = global.projectDbClients[dbPath]; + pdb.run("ALTER TABLE Images ADD COLUMN reviewImage INTEGER NOT NULL DEFAULT 0", () => { + pdb.run("ALTER TABLE Images ADD COLUMN validateImage INTEGER NOT NULL DEFAULT 0", () => { + const query = ` + SELECT Images.IName, Images.reviewImage, Images.validateImage, COUNT(Labels.LID) AS numLabels + FROM Images + LEFT JOIN Labels ON Images.IName = Labels.IName + GROUP BY Images.IName + `; + pdb.all(query, [], (err, rows) => { + if (err) return reject(err); + expect(rows).toBeDefined(); + resolve(); + }); + }); + }); + }); + }); +}); diff --git a/tests/integration/mapKwCocoCsv.test.js b/tests/integration/mapKwCocoCsv.test.js new file mode 100644 index 00000000..5005498f --- /dev/null +++ b/tests/integration/mapKwCocoCsv.test.js @@ -0,0 +1,123 @@ +const request = require('supertest'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const app = require('../../app'); +const queries = require('../../queries/queries'); +const { Client } = require('../../queries/client'); + +describe('POST /api/projects/map-kwcoco-csv', () => { + let tmpDir; + let projectDir; + let originalProjectsPath; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'map-kwcoco-test-')); + projectDir = path.join(__dirname, '..', '..', 'public', 'projects', 'admin-testproj'); + fs.mkdirSync(projectDir, { recursive: true }); + + const mockClient = { + open: jest.fn(), + all: jest.fn().mockImplementation((sql) => { + if (sql.includes('Classes')) return Promise.resolve({ success: true, rows: [] }); + if (sql.includes('Images')) return Promise.resolve({ success: true, rows: [] }); + if (sql.includes('Labels')) return Promise.resolve({ success: true, rows: [] }); + return Promise.resolve({ success: true, rows: [] }); + }), + get: jest.fn().mockResolvedValue({ success: true, row: null }), + run: jest.fn().mockResolvedValue({ success: true, changes: 1, lastID: 1 }), + }; + + global.projectDbClients = { + [projectDir]: mockClient + }; + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('returns 400 when project name is missing', async () => { + const res = await request(app) + .post('/api/projects/map-kwcoco-csv') + .set('Cookie', ['Username=admin']); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/Project name is required/i); + }); + + test('returns 400 when no annotation file is uploaded', async () => { + const res = await request(app) + .post('/api/projects/map-kwcoco-csv') + .field('PName', 'testproj') + .field('Admin', 'admin'); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/No annotation file was uploaded/i); + }); + + test('returns 404 when project path does not exist', async () => { + const csvContent = 'filename,class,xmin,ymin,xmax,ymax\nimg1.jpg,dolphin,10,10,50,50'; + const csvPath = path.join(tmpDir, 'test.csv'); + fs.writeFileSync(csvPath, csvContent); + + const res = await request(app) + .post('/api/projects/map-kwcoco-csv') + .field('PName', 'nonexistent_project') + .field('Admin', 'admin') + .attach('kwcoco_csv', csvPath); + + expect(res.statusCode).toBe(404); + expect(res.body.success).toBe(false); + }); + + test('successfully maps KW COCO CSV annotations', async () => { + const csvContent = `filename,class,xmin,ymin,xmax,ymax +img1.jpg,dolphin,10,20,100,150 +img2.jpg,shark,30,40,80,120`; + const csvPath = path.join(tmpDir, 'test.csv'); + fs.writeFileSync(csvPath, csvContent); + + const res = await request(app) + .post('/api/projects/map-kwcoco-csv') + .field('PName', 'testproj') + .field('Admin', 'admin') + .attach('kwcoco_csv', csvPath); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.labelsInserted).toBe(2); + }); + + test('successfully maps KW COCO JSON annotations', async () => { + const jsonContent = JSON.stringify({ + images: [ + { id: 1, file_name: 'img1.jpg' }, + { id: 2, file_name: 'img2.jpg' } + ], + annotations: [ + { id: 1, image_id: 1, category_id: 1, bbox: [10, 20, 90, 130] }, + { id: 2, image_id: 2, category_id: 2, bbox: [30, 40, 50, 80] } + ], + categories: [ + { id: 1, name: 'dolphin' }, + { id: 2, name: 'shark' } + ] + }); + const jsonPath = path.join(tmpDir, 'test.json'); + fs.writeFileSync(jsonPath, jsonContent); + + const res = await request(app) + .post('/api/projects/map-kwcoco-csv') + .field('PName', 'testproj') + .field('Admin', 'admin') + .attach('kwcoco_json', jsonPath); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.labelsInserted).toBe(2); + }); +}); diff --git a/tests/integration/spinnerViews.test.js b/tests/integration/spinnerViews.test.js new file mode 100644 index 00000000..53912bf5 --- /dev/null +++ b/tests/integration/spinnerViews.test.js @@ -0,0 +1,75 @@ +jest.mock('sqlite3', () => { + const mockDb = { + run: jest.fn((...cbArgs) => { + const cb = cbArgs[cbArgs.length - 1]; + if (typeof cb === 'function') cb(null); + return { lastID: 1, changes: 1 }; + }), + get: jest.fn((...cbArgs) => { + const cb = cbArgs[cbArgs.length - 1]; + if (typeof cb === 'function') cb(null, { user: 'testuser' }); + }), + all: jest.fn((...cbArgs) => { + const cb = cbArgs[cbArgs.length - 1]; + if (typeof cb === 'function') cb(null, []); + }), + close: jest.fn((cb) => cb && cb()), + }; + const mockModule = { + OPEN_CREATE: 1, + OPEN_READWRITE: 2, + OPEN_READONLY: 1, + Database: jest.fn((...args) => { + const cb = args[args.length - 1]; + if (typeof cb === 'function') cb(null); + return mockDb; + }), + verbose: jest.fn().mockImplementation(() => mockModule), + }; + return mockModule; +}); + +const request = require('supertest'); +const app = require('../../app'); + +describe('LoadingSpinner Integration in EJS Views', () => { + beforeAll(() => { + global.db = { + runAsync: jest.fn().mockResolvedValue(undefined), + getAsync: jest.fn().mockResolvedValue({ user: 'testuser' }), + allAsync: jest.fn().mockResolvedValue([]), + }; + }); + + it('serves public/js/spinner.js static file', async () => { + const res = await request(app).get('/js/spinner.js'); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toMatch(/javascript/); + expect(res.text).toContain('LoadingSpinner'); + }); + + it('includes /js/spinner.js in pages using header.ejs', async () => { + const res = await request(app) + .get('/create') + .set('Cookie', ['user=testuser']); + expect(res.statusCode).toBe(200); + expect(res.text).toContain(''); + }); + + it('contains LoadingSpinner integrations in /create view', async () => { + const res = await request(app) + .get('/create') + .set('Cookie', ['user=testuser']); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('LoadingSpinner.show(submitBtn'); + expect(res.text).toContain('s3SubmitBtn'); + }); + + it('contains LoadingSpinner integrations in /createClassification view', async () => { + const res = await request(app) + .get('/createClassification') + .set('Cookie', ['user=testuser']); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('LoadingSpinner.show(submitBtn'); + }); +}); diff --git a/tests/integration/yoloTrainingClassClamp.test.js b/tests/integration/yoloTrainingClassClamp.test.js index b36c6b6a..6ff16f1a 100644 --- a/tests/integration/yoloTrainingClassClamp.test.js +++ b/tests/integration/yoloTrainingClassClamp.test.js @@ -9,117 +9,117 @@ const fs = require('fs'); const path = require('path'); function extractClassSelectionScript() { - const templatePath = path.join(__dirname, '../../views/training/yolovXTrainingSettings.ejs'); - const template = fs.readFileSync(templatePath, 'utf8'); + const templatePath = path.join(__dirname, '../../views/training/yolovXTrainingSettings.ejs'); + const template = fs.readFileSync(templatePath, 'utf8'); - const startMarker = '// Class selection helper buttons'; - const endMarker = '// Split ratio summary update'; - const start = template.indexOf(startMarker); - const end = template.indexOf(endMarker); + const startMarker = '// Class selection helper buttons'; + const endMarker = '// Split ratio summary update'; + const start = template.indexOf(startMarker); + const end = template.indexOf(endMarker); - if (start === -1 || end === -1) { - throw new Error('Could not locate class selection script block in template'); - } + if (start === -1 || end === -1) { + throw new Error('Could not locate class selection script block in template'); + } - return template.slice(start, end); + return template.slice(start, end); } function makeCheckbox(count, checked) { - return { - checked, - getAttribute: jest.fn(() => String(count)), - }; + return { + checked, + getAttribute: jest.fn(() => String(count)), + }; } function makeFakeDocument(checkboxes) { - const elements = {}; - const listeners = {}; - - function makeButtonLike(id) { - const el = { - addEventListener: jest.fn((evt, handler) => { - listeners[id] = listeners[id] || {}; - listeners[id][evt] = handler; - }), - }; - elements[id] = el; - return el; - } - - makeButtonLike('selectAllClasses'); - makeButtonLike('deselectAllClasses'); - - const minInput = makeButtonLike('minClassImages'); - minInput.value = ''; - - const doc = { - getElementById: jest.fn((id) => elements[id]), - querySelectorAll: jest.fn((selector) => { - if (selector === '.class-checkbox') { - return { - forEach: (fn) => checkboxes.forEach(fn), + const elements = {}; + const listeners = {}; + + function makeButtonLike(id) { + const el = { + addEventListener: jest.fn((evt, handler) => { + listeners[id] = listeners[id] || {}; + listeners[id][evt] = handler; + }), }; - } - return { forEach: () => {} }; - }), - }; + elements[id] = el; + return el; + } - return { doc, listeners, minInput }; -} + makeButtonLike('selectAllClasses'); + makeButtonLike('deselectAllClasses'); -describe('yolovXTrainingSettings.ejs class selection + minimum images clamp script', () => { - let checkboxes; - let doc; - let listeners; - let minInput; - - beforeEach(() => { - // Mirrors data-image-count rendered per checkbox: person=10, car=3, dog=0 - checkboxes = [ - makeCheckbox(10, true), - makeCheckbox(3, true), - makeCheckbox(0, true), - ]; - ({ doc, listeners, minInput } = makeFakeDocument(checkboxes)); - - const script = extractClassSelectionScript(); - // eslint-disable-next-line no-new-func - const run = new Function('document', script); - run(doc); - }); - - it('does nothing when the minimum images threshold is empty/zero', () => { + const minInput = makeButtonLike('minClassLabels'); minInput.value = ''; - listeners.minClassImages.input(); - - expect(checkboxes.map((cb) => cb.checked)).toEqual([true, true, true]); - }); - - it('unchecks only classes below the configured minimum on input', () => { - minInput.value = '5'; - listeners.minClassImages.input(); - - expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]); - }); - it('re-applies the clamp when Select All is clicked, so it cannot bypass the minimum', () => { - minInput.value = '5'; - listeners.minClassImages.input(); - expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]); - - listeners.selectAllClasses.click(); - - // Select All checks everything first, then the clamp must immediately re-uncheck - // the classes that don't meet the threshold. - expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]); - }); - - it('Deselect All unchecks everything regardless of the clamp threshold', () => { - minInput.value = '5'; - listeners.minClassImages.input(); + const doc = { + getElementById: jest.fn((id) => elements[id]), + querySelectorAll: jest.fn((selector) => { + if (selector === '.class-checkbox') { + return { + forEach: (fn) => checkboxes.forEach(fn), + }; + } + return { forEach: () => { } }; + }), + }; - listeners.deselectAllClasses.click(); + return { doc, listeners, minInput }; +} - expect(checkboxes.map((cb) => cb.checked)).toEqual([false, false, false]); - }); +describe('yolovXTrainingSettings.ejs class selection + minimum images clamp script', () => { + let checkboxes; + let doc; + let listeners; + let minInput; + + beforeEach(() => { + // Mirrors data-image-count rendered per checkbox: person=10, car=3, dog=0 + checkboxes = [ + makeCheckbox(10, true), + makeCheckbox(3, true), + makeCheckbox(0, true), + ]; + ({ doc, listeners, minInput } = makeFakeDocument(checkboxes)); + + const script = extractClassSelectionScript(); + // eslint-disable-next-line no-new-func + const run = new Function('document', script); + run(doc); + }); + + it('does nothing when the minimum images threshold is empty/zero', () => { + minInput.value = ''; + listeners.minClassLabels.input(); + + expect(checkboxes.map((cb) => cb.checked)).toEqual([true, true, true]); + }); + + it('unchecks only classes below the configured minimum on input', () => { + minInput.value = '5'; + listeners.minClassLabels.input(); + + expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]); + }); + + it('re-applies the clamp when Select All is clicked, so it cannot bypass the minimum', () => { + minInput.value = '5'; + listeners.minClassLabels.input(); + expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]); + + listeners.selectAllClasses.click(); + + // Select All checks everything first, then the clamp must immediately re-uncheck + // the classes that don't meet the threshold. + expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]); + }); + + it('Deselect All unchecks everything regardless of the clamp threshold', () => { + minInput.value = '5'; + listeners.minClassLabels.input(); + + listeners.deselectAllClasses.click(); + + expect(checkboxes.map((cb) => cb.checked)).toEqual([false, false, false]); + }); }); diff --git a/tests/unit/parseKwCocoCsv.test.js b/tests/unit/parseKwCocoCsv.test.js new file mode 100644 index 00000000..3f91e614 --- /dev/null +++ b/tests/unit/parseKwCocoCsv.test.js @@ -0,0 +1,52 @@ +const parseKwCocoCsv = require('../../utils/parseKwCocoCsv'); + +describe('parseKwCocoCsv', () => { + test('returns empty array for invalid or empty input', () => { + expect(parseKwCocoCsv(null)).toEqual([]); + expect(parseKwCocoCsv('')).toEqual([]); + expect(parseKwCocoCsv(' ')).toEqual([]); + }); + + test('parses CSV with header containing filename, class, xmin, ymin, xmax, ymax', () => { + const csv = `filename,class,xmin,ymin,xmax,ymax +image1.jpg,dolphin,10,20,100,150 +image2.png,blue whale,50,60,200,260`; + + const result = parseKwCocoCsv(csv); + expect(result).toEqual([ + { filename: 'image1.jpg', className: 'dolphin', x: 10, y: 20, w: 90, h: 130 }, + { filename: 'image2.png', className: 'blue_whale', x: 50, y: 60, w: 150, h: 200 } + ]); + }); + + test('parses CSV with header containing x, y, w, h', () => { + const csv = `file_name,category,x,y,w,h +path/to/img3.jpg,sea turtle,15,25,80,95`; + + const result = parseKwCocoCsv(csv); + expect(result).toEqual([ + { filename: 'img3.jpg', className: 'sea_turtle', x: 15, y: 25, w: 80, h: 95 } + ]); + }); + + test('parses positional CSV without header', () => { + const csv = `img4.jpg,fish,5,10,45,60`; + + const result = parseKwCocoCsv(csv); + expect(result).toEqual([ + { filename: 'img4.jpg', className: 'fish', x: 5, y: 10, w: 40, h: 50 } + ]); + }); + + test('ignores invalid rows and negative width/height', () => { + const csv = `filename,class,xmin,ymin,xmax,ymax +bad1.jpg,fish,100,100,50,50 +bad2.jpg,fish,invalid,10,20,30 +good.jpg,shark,10,10,30,30`; + + const result = parseKwCocoCsv(csv); + expect(result).toEqual([ + { filename: 'good.jpg', className: 'shark', x: 10, y: 10, w: 20, h: 20 } + ]); + }); +}); diff --git a/tests/unit/parseKwCocoJson.test.js b/tests/unit/parseKwCocoJson.test.js new file mode 100644 index 00000000..cbb6b0de --- /dev/null +++ b/tests/unit/parseKwCocoJson.test.js @@ -0,0 +1,64 @@ +const parseKwCocoJson = require('../../utils/parseKwCocoJson'); + +describe('parseKwCocoJson', () => { + test('returns empty array for invalid or empty input', () => { + expect(parseKwCocoJson(null)).toEqual([]); + expect(parseKwCocoJson('')).toEqual([]); + expect(parseKwCocoJson('not json')).toEqual([]); + expect(parseKwCocoJson('{}')).toEqual([]); + }); + + test('parses standard COCO-style JSON with images/annotations/categories', () => { + const json = JSON.stringify({ + images: [ + { id: 1, file_name: 'image1.jpg' }, + { id: 2, file_name: 'path/to/image2.png' } + ], + annotations: [ + { id: 1, image_id: 1, category_id: 10, bbox: [10, 20, 90, 130] }, + { id: 2, image_id: 2, category_id: 11, bbox: [50, 60, 150, 200] } + ], + categories: [ + { id: 10, name: 'dolphin' }, + { id: 11, name: 'blue whale' } + ] + }); + + const result = parseKwCocoJson(json); + expect(result).toEqual([ + { filename: 'image1.jpg', className: 'dolphin', x: 10, y: 20, w: 90, h: 130 }, + { filename: 'image2.png', className: 'blue_whale', x: 50, y: 60, w: 150, h: 200 } + ]); + }); + + test('skips annotations referencing unknown images or categories', () => { + const json = JSON.stringify({ + images: [{ id: 1, file_name: 'good.jpg' }], + annotations: [ + { id: 1, image_id: 1, category_id: 5, bbox: [10, 10, 20, 20] }, + { id: 2, image_id: 999, category_id: 5, bbox: [10, 10, 20, 20] }, + { id: 3, image_id: 1, category_id: 999, bbox: [10, 10, 20, 20] } + ], + categories: [{ id: 5, name: 'fish' }] + }); + + const result = parseKwCocoJson(json); + expect(result).toEqual([ + { filename: 'good.jpg', className: 'fish', x: 10, y: 10, w: 20, h: 20 } + ]); + }); + + test('ignores annotations with invalid or non-positive bbox dimensions', () => { + const json = JSON.stringify({ + images: [{ id: 1, file_name: 'img.jpg' }], + annotations: [ + { id: 1, image_id: 1, category_id: 1, bbox: [0, 0, 0, 0] }, + { id: 2, image_id: 1, category_id: 1, bbox: ['a', 'b', 'c', 'd'] }, + { id: 3, image_id: 1, category_id: 1, bbox: [1, 2, 3] } + ], + categories: [{ id: 1, name: 'shark' }] + }); + + expect(parseKwCocoJson(json)).toEqual([]); + }); +}); diff --git a/tests/unit/spinner.test.js b/tests/unit/spinner.test.js new file mode 100644 index 00000000..15b4f3b8 --- /dev/null +++ b/tests/unit/spinner.test.js @@ -0,0 +1,81 @@ +/** + * Unit tests for public/js/spinner.js LoadingSpinner helper + */ +const LoadingSpinner = require('../../public/js/spinner.js'); + +describe('LoadingSpinner helper', () => { + let mockElement; + + beforeEach(() => { + // Mock DOM element environment + mockElement = { + tagName: 'BUTTON', + type: 'submit', + disabled: false, + innerHTML: 'Submit Form', + value: 'Submit Form', + classList: { + add: jest.fn(), + remove: jest.fn() + }, + dataset: {}, + querySelectorAll: jest.fn().mockReturnValue([]) + }; + }); + + test('exports LoadingSpinner object with required methods', () => { + expect(LoadingSpinner).toBeDefined(); + expect(typeof LoadingSpinner.show).toBe('function'); + expect(typeof LoadingSpinner.hide).toBe('function'); + expect(typeof LoadingSpinner.showOverlay).toBe('function'); + expect(typeof LoadingSpinner.hideOverlay).toBe('function'); + expect(typeof LoadingSpinner.wrapFetch).toBe('function'); + }); + + test('show() disables element and sets spinner HTML and loading class', () => { + LoadingSpinner.show(mockElement, 'Uploading...'); + + expect(mockElement.disabled).toBe(true); + expect(mockElement.dataset.njobvuOriginalHtml).toBe('Submit Form'); + expect(mockElement.dataset.njobvuOriginalDisabled).toBe('false'); + expect(mockElement.classList.add).toHaveBeenCalledWith('njobvu-btn-loading'); + expect(mockElement.innerHTML).toContain('njobvu-spinner-inline'); + expect(mockElement.innerHTML).toContain('Uploading...'); + }); + + test('show() supports default message when string is omitted', () => { + LoadingSpinner.show(mockElement); + expect(mockElement.innerHTML).toContain('Processing...'); + }); + + test('hide() restores original HTML, disabled status and removes loading class', () => { + LoadingSpinner.show(mockElement, 'Uploading...'); + LoadingSpinner.hide(mockElement); + + expect(mockElement.disabled).toBe(false); + expect(mockElement.innerHTML).toBe('Submit Form'); + expect(mockElement.dataset.njobvuOriginalHtml).toBeUndefined(); + expect(mockElement.dataset.njobvuOriginalDisabled).toBeUndefined(); + expect(mockElement.classList.remove).toHaveBeenCalledWith('njobvu-btn-loading'); + }); + + test('wrapFetch() executes promise and automatically shows then hides spinner', async () => { + const fakeFetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true }) }); + + const res = await LoadingSpinner.wrapFetch(fakeFetch(), mockElement, 'Saving...'); + + expect(fakeFetch).toHaveBeenCalled(); + expect(res).toEqual({ ok: true, json: expect.any(Function) }); + expect(mockElement.disabled).toBe(false); + expect(mockElement.innerHTML).toBe('Submit Form'); + }); + + test('wrapFetch() hides spinner even when promise rejects', async () => { + const fakeFetch = jest.fn().mockRejectedValue(new Error('Network error')); + + await expect(LoadingSpinner.wrapFetch(fakeFetch(), mockElement, 'Saving...')).rejects.toThrow('Network error'); + + expect(mockElement.disabled).toBe(false); + expect(mockElement.innerHTML).toBe('Submit Form'); + }); +}); diff --git a/utils/parseKwCocoCsv.js b/utils/parseKwCocoCsv.js new file mode 100644 index 00000000..b09bf5a3 --- /dev/null +++ b/utils/parseKwCocoCsv.js @@ -0,0 +1,141 @@ +const path = require('path'); + +/** + * Parses KW COCO CSV annotation strings or file contents into structured bounding box records. + * + * Supports header-based CSVs (filename/file_name, class/category/label, xmin/x/bbox_x, ymin/y/bbox_y, xmax/w/bbox_w, ymax/h/bbox_h) + * and positional/VIAME CSVs (filename, class, xmin/x, ymin/y, xmax/w, ymax/h). + * + * @param {string} csvContent - Raw CSV string content + * @returns {Array<{filename: string, className: string, x: number, y: number, w: number, h: number}>} Parsed annotation objects + */ +function parseKwCocoCsv(csvContent) { + if (!csvContent || typeof csvContent !== 'string') { + return []; + } + + const lines = csvContent + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0 && !line.startsWith('#')); + + if (lines.length === 0) { + return []; + } + + const firstLine = lines[0]; + const rawTokens = firstLine.split(',').map(t => t.trim()); + const lowerTokens = rawTokens.map(t => t.toLowerCase()); + + const hasHeader = lowerTokens.some(t => + ['filename', 'file_name', 'file', 'image', 'class', 'category', 'label', 'xmin', 'x', 'ymin', 'y', 'xmax', 'w', 'ymax', 'h', 'bbox_x'].includes(t) + ); + + let filenameIdx = 0; + let classIdx = 1; + let xIdx = 2; + let yIdx = 3; + let wOrXmaxIdx = 4; + let hOrYmaxIdx = 5; + let isWidthHeight = false; + + let startLine = 0; + + if (hasHeader) { + startLine = 1; + + filenameIdx = lowerTokens.findIndex(t => ['filename', 'file_name', 'file', 'image', 'iname', 'image_name'].includes(t)); + if (filenameIdx === -1) filenameIdx = 0; + + classIdx = lowerTokens.findIndex(t => ['class', 'category', 'label', 'cname', 'class_name'].includes(t)); + if (classIdx === -1) classIdx = 1; + + xIdx = lowerTokens.findIndex(t => ['xmin', 'x', 'left_x', 'left', 'bbox_x', 'tl_x'].includes(t)); + if (xIdx === -1) xIdx = 2; + + yIdx = lowerTokens.findIndex(t => ['ymin', 'y', 'top_y', 'top', 'bbox_y', 'tl_y'].includes(t)); + if (yIdx === -1) yIdx = 3; + + const wIdx = lowerTokens.findIndex(t => ['w', 'width', 'box_w', 'bbox_w'].includes(t)); + const xmaxIdx = lowerTokens.findIndex(t => ['xmax', 'right_x', 'right', 'br_x'].includes(t)); + + if (wIdx !== -1) { + wOrXmaxIdx = wIdx; + isWidthHeight = true; + } else if (xmaxIdx !== -1) { + wOrXmaxIdx = xmaxIdx; + isWidthHeight = false; + } else { + wOrXmaxIdx = 4; + } + + const hIdx = lowerTokens.findIndex(t => ['h', 'height', 'box_h', 'bbox_h'].includes(t)); + const ymaxIdx = lowerTokens.findIndex(t => ['ymax', 'bottom_y', 'bottom', 'br_y'].includes(t)); + + if (hIdx !== -1) { + hOrYmaxIdx = hIdx; + } else if (ymaxIdx !== -1) { + hOrYmaxIdx = ymaxIdx; + } else { + hOrYmaxIdx = 5; + } + } + + const results = []; + + for (let i = startLine; i < lines.length; i++) { + const row = lines[i].split(',').map(col => col.trim()); + if (row.length <= Math.max(filenameIdx, classIdx, xIdx, yIdx, wOrXmaxIdx, hOrYmaxIdx)) { + continue; + } + + const rawFilename = row[filenameIdx]; + const rawClass = row[classIdx]; + + if (!rawFilename || !rawClass) { + continue; + } + + const filename = path.basename(rawFilename.replace(/\\/g, '/')); + const className = rawClass.replace(/\s+/g, '_'); + + const xVal = parseFloat(row[xIdx]); + const yVal = parseFloat(row[yIdx]); + const val4 = parseFloat(row[wOrXmaxIdx]); + const val5 = parseFloat(row[hOrYmaxIdx]); + + if (isNaN(xVal) || isNaN(yVal) || isNaN(val4) || isNaN(val5)) { + continue; + } + + let x = Math.round(xVal); + let y = Math.round(yVal); + let w = 0; + let h = 0; + + if (isWidthHeight) { + w = Math.round(val4); + h = Math.round(val5); + } else { + w = Math.round(val4 - xVal); + h = Math.round(val5 - yVal); + } + + if (w <= 0 || h <= 0) { + continue; + } + + results.push({ + filename, + className, + x, + y, + w, + h + }); + } + + return results; +} + +module.exports = parseKwCocoCsv; diff --git a/utils/parseKwCocoJson.js b/utils/parseKwCocoJson.js new file mode 100644 index 00000000..f81d0b06 --- /dev/null +++ b/utils/parseKwCocoJson.js @@ -0,0 +1,92 @@ +const path = require('path'); + +/** + * Parses KW COCO / COCO-style JSON annotation content into the same + * {filename, className, x, y, w, h} shape produced by parseKwCocoCsv, so both + * formats can feed the same downstream label-import pipeline. + * + * Expects the standard COCO structure: {images: [...], annotations: [...], categories: [...]} + * with annotations referencing images/categories by id and bbox as [x, y, w, h]. + * + * @param {string} jsonContent - Raw JSON string content + * @returns {Array<{filename: string, className: string, x: number, y: number, w: number, h: number}>} Parsed annotation objects + */ +function parseKwCocoJson(jsonContent) { + if (!jsonContent || typeof jsonContent !== 'string') { + return []; + } + + let data; + try { + data = JSON.parse(jsonContent); + } catch (err) { + return []; + } + + if (!data || typeof data !== 'object') { + return []; + } + + const images = Array.isArray(data.images) ? data.images : []; + const annotations = Array.isArray(data.annotations) ? data.annotations : []; + const categories = Array.isArray(data.categories) ? data.categories : []; + + if (images.length === 0 || annotations.length === 0) { + return []; + } + + const imageIdToFilename = new Map(); + for (const img of images) { + if (!img || img.id == null) continue; + const rawName = img.file_name || img.filename || img.name; + if (!rawName) continue; + imageIdToFilename.set(img.id, path.basename(String(rawName).replace(/\\/g, '/'))); + } + + const categoryIdToName = new Map(); + for (const cat of categories) { + if (!cat || cat.id == null) continue; + const rawName = cat.name || cat.category_name; + if (!rawName) continue; + categoryIdToName.set(cat.id, String(rawName).replace(/\s+/g, '_')); + } + + const results = []; + + for (const ann of annotations) { + if (!ann) continue; + + const filename = imageIdToFilename.get(ann.image_id); + if (!filename) continue; + + const className = categoryIdToName.get(ann.category_id); + if (!className) continue; + + const bbox = ann.bbox; + if (!Array.isArray(bbox) || bbox.length < 4) continue; + + const xVal = parseFloat(bbox[0]); + const yVal = parseFloat(bbox[1]); + const wVal = parseFloat(bbox[2]); + const hVal = parseFloat(bbox[3]); + + if (isNaN(xVal) || isNaN(yVal) || isNaN(wVal) || isNaN(hVal)) { + continue; + } + + const x = Math.round(xVal); + const y = Math.round(yVal); + const w = Math.round(wVal); + const h = Math.round(hVal); + + if (w <= 0 || h <= 0) { + continue; + } + + results.push({ filename, className, x, y, w, h }); + } + + return results; +} + +module.exports = parseKwCocoJson; diff --git a/views/create.ejs b/views/create.ejs index 7562b8bc..f8802759 100644 --- a/views/create.ejs +++ b/views/create.ejs @@ -297,7 +297,7 @@ const submitBtn = document.getElementById('s3SubmitBtn'); const progress = document.getElementById('progress'); - submitBtn.style.display = "none"; + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Creating project..."); try { progress.textContent = "Creating project..."; @@ -316,6 +316,7 @@ } progress.textContent = "Attaching S3 bucket..."; + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Attaching S3 bucket..."); const attachRes = await fetch(`/api/v2/projects/${encodeURIComponent(currentUsername)}/${encodeURIComponent(projectName)}/s3-bucket`, { method: 'POST', @@ -336,6 +337,7 @@ } progress.textContent = "Syncing images from S3..."; + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Syncing images from S3..."); const syncRes = await fetch(`/api/v2/projects/${encodeURIComponent(currentUsername)}/${encodeURIComponent(projectName)}/s3-bucket/sync`, { method: 'POST', @@ -351,7 +353,7 @@ window.location.replace('/home'); } catch (err) { progress.textContent = ""; - submitBtn.style.display = "inline-block"; + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); alert("Something went wrong setting up the S3-backed project.\nReason: " + err.message); } } @@ -375,23 +377,26 @@ const formData = new FormData(this); const xhr = new XMLHttpRequest(); const progress = document.getElementById('progress'); + const submitBtn = this.querySelector('input[type="submit"], button[type="submit"]'); progress.textContent = "Percent Uploaded: 0%"; - document.querySelectorAll('input[type="submit"]').forEach(btn => btn.style.display = "none"); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Uploading 0%..."); xhr.upload.addEventListener('progress', function(e) { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); progress.textContent = `Percent Uploaded: ${percent}%`; + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, `Uploading ${percent}%...`); if (percent === 100) { progress.textContent = "Upload Finished. Server processing upload. Please wait for the closing prompt."; + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Processing..."); } } }); xhr.onreadystatechange = function() { if (xhr.readyState === 4) { - document.querySelectorAll('input[type="submit"]').forEach(btn => btn.style.display = "inline-block"); + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); if (xhr.status === 200) { alert("Project creation successful"); @@ -427,7 +432,7 @@ formData.append('frame_rate', frameRate); } else { progress.textContent = ""; - document.querySelectorAll('input[type="submit"]').forEach(btn => btn.style.display = "inline-block"); + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); return; } } @@ -449,7 +454,7 @@ formData.append('frame_rate', frameRate); } else { progress.textContent = ""; - document.querySelectorAll('input[type="submit"]').forEach(btn => btn.style.display = "inline-block"); + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); return; } } diff --git a/views/createClassification.ejs b/views/createClassification.ejs index d217cf8f..ca9cf441 100644 --- a/views/createClassification.ejs +++ b/views/createClassification.ejs @@ -80,7 +80,8 @@ var messageDiv = document.getElementById('response-message'); messageDiv.innerHTML = '
Importing... Please wait.
'; - + var submitBtn = this.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Importing..."); fetch('/api/projects/import-dataset', { method: 'POST', @@ -92,10 +93,12 @@ messageDiv.innerHTML = '
Import successful! Redirecting to home...
'; setTimeout(() => window.location.replace("/home"), 2000); } else { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); messageDiv.innerHTML = '
Error: ' + data.message + '
'; } }) .catch(error => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); messageDiv.innerHTML = '
Error: ' + error + '
'; }); }); diff --git a/views/customTraining.ejs b/views/customTraining.ejs index a5ce97ca..399bcf01 100644 --- a/views/customTraining.ejs +++ b/views/customTraining.ejs @@ -196,7 +196,8 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); - console.log(files) + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Adding file..."); fetch(event.target.action, { method: 'POST', @@ -209,6 +210,7 @@ window.location.replace("/training?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); }); @@ -232,6 +234,8 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Uploading weights..."); fetch(event.target.action, { method: 'POST', @@ -244,6 +248,7 @@ window.location.replace("/training?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); } @@ -259,6 +264,8 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Adding path..."); fetch(event.target.action, { method: 'POST', @@ -272,6 +279,7 @@ window.location.replace("/training?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); }); @@ -302,6 +310,8 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = document.getElementById("Trainbtn"); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Starting training..."); fetch(event.target.action, { method: 'POST', @@ -315,6 +325,7 @@ window.location.replace("/training?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); }); diff --git a/views/includes/header.ejs b/views/includes/header.ejs index 8149a840..a6ef1ce8 100644 --- a/views/includes/header.ejs +++ b/views/includes/header.ejs @@ -18,6 +18,7 @@ + diff --git a/views/inference.ejs b/views/inference.ejs index 00f7f563..1fe1be21 100644 --- a/views/inference.ejs +++ b/views/inference.ejs @@ -450,8 +450,12 @@ document.addEventListener("click", async function (e) { const runTimestamp = btn.dataset.timestamp; const minConfidence = confInput.value; - btn.disabled = true; - btn.textContent = "Adding..."; + if (window.LoadingSpinner) { + LoadingSpinner.show(btn, "Adding..."); + } else { + btn.disabled = true; + btn.textContent = "Adding..."; + } try { const response = await fetch("/inference/add-inference-run-to-dataset", { @@ -476,8 +480,12 @@ document.addEventListener("click", async function (e) { } catch (err) { alert(`Request failed: ${err.message}`); } finally { - btn.disabled = false; - btn.textContent = "Add to Training Set"; + if (window.LoadingSpinner) { + LoadingSpinner.hide(btn); + } else { + btn.disabled = false; + btn.textContent = "Add to Training Set"; + } } }); diff --git a/views/project.ejs b/views/project.ejs index ee023a78..ae98969f 100644 --- a/views/project.ejs +++ b/views/project.ejs @@ -159,12 +159,15 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + if (window.LoadingSpinner) LoadingSpinner.showOverlay("Deleting image..."); + fetch('/deleteImage', { method: 'POST', body: formData }).then(() => { window.location.reload(); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hideOverlay(); console.log(error); }); } diff --git a/views/settings/projSettings.ejs b/views/settings/projSettings.ejs index 8f9d1f3c..1f791f8f 100644 --- a/views/settings/projSettings.ejs +++ b/views/settings/projSettings.ejs @@ -93,6 +93,31 @@
+
+

+ Pre-Processing: Map KW COCO Annotations +

+

+ Upload a `.csv` or `.json` annotation file (KW COCO format) to map bounding box labels directly to project images. +

+
+ + + +
+ + +
+
+ <% if(user == Admin) { %> + + <% } else { %> + Save + <% } %> +
+
+
+

Delete Images Without Label

@@ -237,4 +262,39 @@ request.send(formData); console.log(formData); }); + + if (document.forms['mapKwCocoCsvForm']) { + document.forms['mapKwCocoCsvForm'].addEventListener('submit', (event) => { + event.preventDefault(); + var formData = new FormData(); + const csvFileInput = document.getElementById('kwcoco_csv'); + if (!csvFileInput || !csvFileInput.files[0]) { + alert("Please select a .csv file to upload."); + return; + } + formData.append('kwcoco_csv', csvFileInput.files[0]); + formData.append("PName", "<%= PName %>"); + formData.append("Admin", "<%= Admin %>"); + formData.append("IDX", "<%= IDX %>"); + + var request = new XMLHttpRequest(); + request.open("post", event.target.action, true); + request.onreadystatechange = function receiveResponse() { + if (this.readyState == 4) { + if (this.status == 200) { + try { + var res = JSON.parse(this.response); + alert(res.message || "Annotations successfully mapped!"); + } catch (e) { + alert("Annotations mapped successfully."); + } + window.location.reload(); + } else { + alert("Failed to map annotations: " + this.response); + } + } + } + request.send(formData); + }); + } diff --git a/views/training/inceptionSettings.ejs b/views/training/inceptionSettings.ejs index fada25f2..4b08d420 100644 --- a/views/training/inceptionSettings.ejs +++ b/views/training/inceptionSettings.ejs @@ -225,6 +225,9 @@ document.forms['add-weights'].addEventListener('submit', (event) => { formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Uploading weights..."); + fetch(event.target.action, { method: 'POST', body: formData @@ -235,6 +238,7 @@ document.forms['add-weights'].addEventListener('submit', (event) => { window.location.replace("/inference/inceptionSettings?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); } @@ -271,6 +275,9 @@ document.getElementById("inception-inf").addEventListener('submit', (event) => { formData.append("using_imagenet_classes", document.getElementById("ImageNet").checked); formData.append("topK", topK); + const submitBtn = document.getElementById("Infbtn"); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Running inference..."); + fetch(event.target.action, { method: 'POST', body: formData @@ -282,6 +289,7 @@ document.getElementById("inception-inf").addEventListener('submit', (event) => { window.location.replace("/inference?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); }); diff --git a/views/training/megadetectorSettings.ejs b/views/training/megadetectorSettings.ejs index b2f7f09d..de9b51ad 100644 --- a/views/training/megadetectorSettings.ejs +++ b/views/training/megadetectorSettings.ejs @@ -125,6 +125,9 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = document.getElementById("MdInfUploadBtn"); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Uploading file..."); + fetch(event.target.action, { method: 'POST', body: formData @@ -134,6 +137,7 @@ alert(body.Success); window.location.replace("/megadetector/settings?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); }); @@ -159,6 +163,9 @@ formData.append("fps", fps); formData.append("inference_file", inferenceFile); + const submitBtn = document.getElementById("MdInfBtn"); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Running MegaDetector..."); + fetch(event.target.action, { method: 'POST', body: formData @@ -168,6 +175,7 @@ alert(body.Success); window.location.replace("/inference?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); }); diff --git a/views/training/yolov3Settings.ejs b/views/training/yolov3Settings.ejs index 6ecbc7f9..4c1b5d93 100644 --- a/views/training/yolov3Settings.ejs +++ b/views/training/yolov3Settings.ejs @@ -156,6 +156,8 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Uploading weights..."); fetch(event.target.action, { method: 'POST', @@ -168,6 +170,7 @@ window.location.replace("/yolo/yolov3Settings?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); } @@ -184,6 +187,8 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Adding path..."); fetch(event.target.action, { method: 'POST', @@ -197,6 +202,7 @@ window.location.replace("/yolo/yolov3Settings?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); }); @@ -236,6 +242,9 @@ formData.append("imgsz", document.getElementById("imgsz").value); formData.append("device", document.getElementById("device").value); + const submitBtn = document.getElementById("Configbtn"); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Starting training..."); + fetch(event.target.action, { method: 'POST', body: formData @@ -248,6 +257,7 @@ window.location.replace("/yolo/yolov3Settings?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); }); diff --git a/views/training/yolovXInferenceSettings.ejs b/views/training/yolovXInferenceSettings.ejs index 7d1ce7f4..c1391db8 100644 --- a/views/training/yolovXInferenceSettings.ejs +++ b/views/training/yolovXInferenceSettings.ejs @@ -228,6 +228,8 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Uploading file..."); fetch(event.target.action, { method: 'POST', @@ -240,6 +242,7 @@ window.location.replace("/yolo/yolovXInferenceSettings?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); } @@ -262,6 +265,8 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Uploading weights..."); fetch(event.target.action, { method: 'POST', @@ -274,6 +279,7 @@ window.location.replace("/yolo/yolovXInferenceSettings?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); } @@ -289,6 +295,8 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Adding path..."); fetch(event.target.action, { method: 'POST', @@ -302,6 +310,7 @@ window.location.replace("/yolo/yolovXInferenceSettings?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); }); @@ -343,10 +352,9 @@ formData.append("yolo_version", document.getElementById("yolo_version").value); formData.append("yolo_task", document.getElementById("yolo_task").value); formData.append("yolo_mode", document.getElementById("yolo_mode").value); - // formData.append("options", document.getElementById("options").value); - // formData.append("device", document.getElementById("device").value); - console.log(formData); + const submitBtn = document.getElementById("Infbtn"); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Running inference..."); fetch(event.target.action, { method: 'POST', @@ -360,58 +368,65 @@ window.location.replace("/inference?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); }); - document.forms['yolo-run'].addEventListener('submit', (event) => { - event.preventDefault(); - console.log('Form for yolo-run handler') + if (document.forms['yolo-run']) { + document.forms['yolo-run'].addEventListener('submit', (event) => { + event.preventDefault(); + console.log('Form for yolo-run handler') - var yolovx_path = document.getElementById("Yolo").value; - var weights_file = document.getElementById("Weights").value; - var inference_file = document.getElementById("Inference").value; + var yolovx_path = document.getElementById("Yolo").value; + var weights_file = document.getElementById("Weights").value; + var inference_file = document.getElementById("Inference").value; - console.log("weights_file: ", weights_file) - if (weights_file == "") { - alert("Must select a weights file") - return; - } + console.log("weights_file: ", weights_file) + if (weights_file == "") { + alert("Must select a weights file") + return; + } - var formData = new FormData(); - formData.append("PName", "<%= PName %>"); - formData.append("Admin", "<%= Admin %>"); - formData.append("TrainingPercent", document.getElementById("TrainingRange").value); - formData.append("batch", document.getElementById("batch").value); - formData.append("subdiv", document.getElementById("subdiv").value); - formData.append("yolovx_path", document.getElementById("Yolo").value); - formData.append("weights", document.getElementById("Weights").value); - formData.append("width", document.getElementById("width").value); - formData.append("height", document.getElementById("height").value); - formData.append("yolo_version", document.getElementById("yolo_version").value); - formData.append("yolo_task", document.getElementById("yolo_task").value); - formData.append("yolo_mode", document.getElementById("yolo_mode").value); - formData.append("epochs", document.getElementById("epochs").value); - formData.append("options", document.getElementById("options").value); - formData.append("imgsz", document.getElementById("imgsz").value); - formData.append("device", document.getElementById("device").value); + var formData = new FormData(); + formData.append("PName", "<%= PName %>"); + formData.append("Admin", "<%= Admin %>"); + formData.append("TrainingPercent", document.getElementById("TrainingRange").value); + formData.append("batch", document.getElementById("batch").value); + formData.append("subdiv", document.getElementById("subdiv").value); + formData.append("yolovx_path", document.getElementById("Yolo").value); + formData.append("weights", document.getElementById("Weights").value); + formData.append("width", document.getElementById("width").value); + formData.append("height", document.getElementById("height").value); + formData.append("yolo_version", document.getElementById("yolo_version").value); + formData.append("yolo_task", document.getElementById("yolo_task").value); + formData.append("yolo_mode", document.getElementById("yolo_mode").value); + formData.append("epochs", document.getElementById("epochs").value); + formData.append("options", document.getElementById("options").value); + formData.append("imgsz", document.getElementById("imgsz").value); + formData.append("device", document.getElementById("device").value); + + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Starting..."); - fetch(event.target.action, { - method: 'POST', - body: formData - }).then((resp) => { - return resp.json(); - }).then((body) => { - console.log(body); + fetch(event.target.action, { + method: 'POST', + body: formData + }).then((resp) => { + return resp.json(); + }).then((body) => { + console.log(body); - alert(body.Success); - window.location.replace("/inference?IDX=<%= IDX %>"); + alert(body.Success); + window.location.replace("/inference?IDX=<%= IDX %>"); - }).catch((error) => { - console.log(error); + }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); + console.log(error); + }); }); - }); + } }); diff --git a/views/training/yolovXTrainingSettings.ejs b/views/training/yolovXTrainingSettings.ejs index 3f89f090..55c1c3a8 100755 --- a/views/training/yolovXTrainingSettings.ejs +++ b/views/training/yolovXTrainingSettings.ejs @@ -163,16 +163,16 @@ - - + +
<% if (typeof classes !== 'undefined' && classes.length > 0) { %> <% for (var i = 0; i < classes.length; i++) { %>
- - + +
<% } %> <% } else { %> @@ -282,6 +282,8 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Uploading weights..."); fetch(event.target.action, { method: 'POST', @@ -294,6 +296,7 @@ window.location.replace("/yolo/yolovXSettings?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); } @@ -309,6 +312,8 @@ formData.append("PName", "<%= PName %>"); formData.append("Admin", "<%= Admin %>"); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Adding path..."); fetch(event.target.action, { method: 'POST', @@ -322,6 +327,7 @@ window.location.replace("/yolo/yolovXSettings?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); }); @@ -329,12 +335,12 @@ // Class selection helper buttons var selectAllBtn = document.getElementById("selectAllClasses"); var deselectAllBtn = document.getElementById("deselectAllClasses"); - var minClassImagesInput = document.getElementById("minClassImages"); + var minClassLabelsInput = document.getElementById("minClassLabels"); - // Unchecks any class checkbox whose image count is below the configured minimum, + // Unchecks any class checkbox whose label count is below the configured minimum, // so Select All (or manually re-checking a box) can't bypass the clamp. - function applyMinClassImagesClamp() { - var threshold = parseInt(minClassImagesInput && minClassImagesInput.value, 10) || 0; + function applyMinClassLabelsClamp() { + var threshold = parseInt(minClassLabelsInput && minClassLabelsInput.value, 10) || 0; if (threshold <= 0) { return; } @@ -350,7 +356,7 @@ if (selectAllBtn) { selectAllBtn.addEventListener("click", function() { document.querySelectorAll(".class-checkbox").forEach(function(cb) { cb.checked = true; }); - applyMinClassImagesClamp(); + applyMinClassLabelsClamp(); }); } if (deselectAllBtn) { @@ -358,8 +364,8 @@ document.querySelectorAll(".class-checkbox").forEach(function(cb) { cb.checked = false; }); }); } - if (minClassImagesInput) { - minClassImagesInput.addEventListener("input", applyMinClassImagesClamp); + if (minClassLabelsInput) { + minClassLabelsInput.addEventListener("input", applyMinClassLabelsClamp); } // Split ratio summary update @@ -419,6 +425,9 @@ formData.append("imgsz", document.getElementById("imgsz").value); formData.append("device", document.getElementById("device").value); + const submitBtn = document.getElementById("Configbtn"); + if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Starting training..."); + fetch(event.target.action, { method: 'POST', body: formData @@ -431,6 +440,7 @@ window.location.replace("/training?IDX=<%= IDX %>"); }).catch((error) => { + if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn); console.log(error); }); });