liutao841204头像
关注
TrainEye源码解读-04. 数据集管理 · 全流程逐行封面图

TrainEye源码解读-04. 数据集管理 · 全流程逐行

覆盖文件:app/static/js/dataset-manager.js(1914 行,前端)、app/api/endpoints/datasets.py(535 行)、app/services/dataset_service.py(2235 行)、app/crud/crud_dataset.py + app/crud/base.pyapp/models/dataset.pyapp/schemas/dataset.py
本系列从 03 篇的框架出发,这次完整走通一个功能:上传 ZIP 数据集 → 解压 → 验证结构 → 入库 → 列表展示 → 分割 → 删除
建议对照《00 篇》的请求流程图阅读。本篇较长,但读完后你会理解"一个功能模块"的全部零件。


一、功能总览

用户操作前端函数后端接口后端服务
进入数据集页面loadDatasets()GET /api/datasets/get_datasets()
点"上传ZIP数据集"showAddDatasetModal()submitAddDataset()POST /api/datasets/upload-initPOST /api/datasets/ → 轮询 GET /api/datasets/upload-status/{id}create_dataset()
点"服务器数据集"showImportLocalDatasetModal()submitImportLocalDataset()POST /api/datasets/import-localimport_local_dataset()
点"分割数据集"showSplitDatasetModal()submitSplitDataset()POST /api/datasets/{id}/splitsplit_dataset()
点"删除"deleteDataset()DELETE /api/datasets/{id}delete_dataset()

本章主线:上传 ZIP 数据集(最核心、代码量最大),其余功能顺带讲清。


二、前端:dataset-manager.js

文件结构:一个 class DatasetManager(页面进入时 main.js 调用它的两个方法)+ 文件末尾创建全局实例。

2.1 加载数据集列表 loadDatasets()(84~203 行)

1: // 数据集管理模块
2: class DatasetManager {
3:     constructor() {
4:         // 数据集管理相关属性
5:     }

▶ 第 2 行:定义类。第 3~5 行:构造函数(这里没放属性,所以是空壳)。main.js 第 791 行 new DatasetManager() 时执行。

84:     loadDatasets() {
85:         console.log('Loading datasets...');
88:         const tableHead = document.querySelector('#datasets-template table thead tr');
89:         if (tableHead) {
90:             const columnCount = tableHead.querySelectorAll('th').length;
91:             console.log('Dataset table has', columnCount, 'columns');
92:         }

▶ 第 88~92 行:调试代码——检查表格有几列(前端排查"列对不上"用的)。querySelector('#datasets-template table thead tr') = 用 CSS 选择器找模板里的表头行。

94:         authenticatedFetch(`${API_URL}/datasets/`)
95:             .then(response => {
96:                 console.log('Datasets response status:', response.status);
97:                 return response.json();
98:             })

▶ 第 94 行:发请求——GET /api/datasets/(带令牌,后端认证见 02 篇)。/datasets/ 末尾的斜杠是关键:FastAPI 的 @router.get("/") 挂在 /datasets 前缀下,完整路径是 /api/datasets/
▶ 第 97 行:解析 JSON(数组:每个元素是一个数据集)。

99:             .then(datasets => {
100:                 console.log('Received', datasets.length, 'datasets');
102:                 const tableBody = document.getElementById('datasets-table-body');
103:                 if (!tableBody) {
106:                     console.log('datasets-table-body element not found, skipping dataset update');
107:                     return;
108:                 }
110:                 tableBody.innerHTML = '';
112:                 if (datasets.length === 0) {
113:                     tableBody.innerHTML = '<tr><td colspan="9" class="text-center">暂无数据集</td></tr>';
114:                     return;
115:                 }

▶ 第 102 行:找模板里的空表格体(03 篇说的"空壳表格")。
▶ 第 103~108 行:找不到就不画(用户不在数据集页面时是正常现象,静默返回——这就是为什么切换页面后 loadPage 还会调 loadDatasets 也不会报错)。
▶ 第 110 行:清空表格体(重新渲染)。
▶ 第 112~115 行:空数据 → 显示"暂无数据集"占位行(colspan="9" = 横跨 9 列)。

124:                 datasets.forEach(dataset => {
126:                     const trainCount = dataset.train_count !== undefined ? dataset.train_count : '-';
127:                     const valCount = dataset.val_count !== undefined ? dataset.val_count : '-';
128:                     const testCount = dataset.test_count !== undefined ? dataset.test_count : '-';

▶ 第 124 行:forEach = 遍历每个数据集。
▶ 第 126~128 行:取数量,没有就用 ‘-’ 兜底(后端不同版本字段可能缺,防御写法)。

132:                     const row = document.createElement('tr');
133:                     row.classList.add('text-center');

▶ 第 132~133 行:动态创建一行表格(JS 创建元素的标准三步:createElement → 设置类 → 后面 appendChild)。

149:                     const taskType = dataset.task || 'detection';
150:                     const taskTypeText = this.getTaskTypeText(taskType);
152:                     // 限制类别显示数量,如果类别过多则显示省略号
153:                     const maxDisplayClasses = 5;
154:                     const displayedClasses = dataset.classes.length <= maxDisplayClasses 
155:                         ? dataset.classes.join(', ') 
156:                         : dataset.classes.slice(0, maxDisplayClasses).join(', ') + '... (共' + dataset.classes.length + '个)';

▶ 第 149 行:dataset.task || 'detection' = 空值兜底(|| 在 JS 里返回第一个真值)。
▶ 第 153~156 行:三元运算符条件 ? A : B = 条件成立取 A 否则取 B):类别 ≤5 个全显示,否则截前 5 个加省略号。slice(0, 5) = 取数组前 5 个;join(', ') = 数组拼成字符串。

158:                     row.innerHTML = `
159:                         <td>${datasetNameHtml}</td>
160:                         <td>${dataset.description || '-'}</td>
...
166:                         <td><span class="badge ${this.getStatusBadgeClass(dataset.status)}">${this.getStatusText(dataset.status)}</span></td>
167:                         <td>${new Date(dataset.created_at).toLocaleString()}</td>
168:                         <td>
169:                             <div class="btn-group">
170:                                 <button class="btn btn-sm btn-info split-dataset-btn" data-id="${dataset.id}">分割数据集</button>
171:                                 <button class="btn btn-sm btn-danger delete-dataset" data-id="${dataset.id}">删除</button>
172:                             </div>
173:                         </td>
174:                     `;
175:                     tableBody.appendChild(row);
176:                 });

▶ 第 158~174 行:模板字符串生成整行 HTML${} 嵌入变量)。核心要点:

  • 第 166 行:状态用徽章(badge)显示,颜色由 getStatusBadgeClass(38~59 行:available→绿、error→红…)决定
  • 第 170~171 行:操作按钮带 data-id="${dataset.id}" ——把数据集 ID 藏在按钮属性里,点击时读出来
  • ⚠️ 注意:直接用 ${} 插值有 XSS 风险(数据集名称含 <script> 会被执行)。项目自己上传的数据风险可控,但这是老式拼接 HTML 的典型缺点
178:                 // 绑定分割按钮事件
180:                 const splitButtons = document.querySelectorAll('.split-dataset-btn');
183:                 splitButtons.forEach(button => {
184:                     button.addEventListener('click', (e) => {
185:                         const datasetId = e.target.getAttribute('data-id');
187:                         this.showSplitDatasetModal(datasetId);
188:                     });
189:                 });
192:                 document.querySelectorAll('.delete-dataset').forEach(button => {
193:                     button.addEventListener('click', (e) => {
194:                         const datasetId = e.target.getAttribute('data-id');
195:                         this.deleteDataset(datasetId);
196:                     });
197:                 });

▶ 第 183~189 行:动态绑定——按钮是刚生成的,必须现在逐个绑事件(静态绑定只对初始 HTML 有效)。
▶ 第 185 行:e.target = 被点击的元素;getAttribute('data-id') = 读第 171 行存的 ID。
💡 重要模式:每次渲染完都要"补绑定事件"——这是无框架 JS 的常规操作。

2.2 页面事件绑定 bindDatasetEvents()(206~242 行)

210:         const addButton = document.getElementById('add-dataset-btn');
211:         if (addButton) {
212:             addButton.addEventListener('click', () => this.showAddDatasetModal());
213:         } else {
215:             console.error('add-dataset-btn not found');
216:         }

▶ 第 210~216 行:给"上传ZIP数据集"按钮绑定 → 打开添加弹窗。注意第 211 行的存在性检查(元素可能因为模板没加载而不存在,直接 .addEventListener 会崩溃)。

219:         const importLocalButton = document.getElementById('import-local-dataset-btn');
220:         if (importLocalButton) {
222:             importLocalButton.addEventListener('click', () => {
224:                 this.showImportLocalDatasetModal();
225:             });
226:         }
231:         const convertCocoButton = document.getElementById('convert-coco-btn');
232:         if (convertCocoButton) {
234:             convertCocoButton.onclick = () => {
236:                 this.showConvertCocoModal();
237:                 return false;
238:             };
239:         }

▶ 第 234 行:注意这里用 onclick = ... 直接赋值(不是 addEventListener)。两者区别:addEventListener 可绑多个,onclick 会覆盖之前的。混用容易出 bug(03 篇提到 main.js 5 秒后强制绑定 COCO 按钮就是因为这俩方式混用踩过坑)。

2.3 打开"添加数据集"弹窗 showAddDatasetModal()(245~303 行)

247:         const modalTitle = document.querySelector('.modal-title');
248:         const modalBody = document.querySelector('.modal-body');
249:         const modalSubmit = document.getElementById('modalSubmit');
256:         modalTitle.textContent = '添加数据集';
259:         // 获取模板内容
260:         const template = document.getElementById('add-dataset-template');
261:         if (!template) {
262:             console.error('add-dataset-template not found');
263:             return;
264:         }
265:         modalBody.innerHTML = template.innerHTML;

▶ 第 247~249 行:拿全局弹窗(mainModal)的三个部件:标题、内容区、确定按钮。
▶ 第 260~265 行:add-dataset-template(index.html 第 1757 行)的内容塞进弹窗内容区——弹窗里显示的就是模板里的表单(名称、描述、文件选择、分割选项等)。

274:         modal.show();
276:         // 使用setTimeout确保 DOM 已经更新
277:         setTimeout(() => {
278:             // 绑定分割选项显示/隐藏
279:             const splitCheckbox = document.getElementById('split-dataset-enabled');
280:             const splitOptions = document.getElementById('split-options');
284:             if (splitCheckbox && splitOptions) {
286:                 splitOptions.style.display = 'none';
289:                 splitCheckbox.onchange = function() {
290:                     console.log('Split checkbox changed:', this.checked);
291:                     splitOptions.style.display = this.checked ? 'block' : 'none';
292:                 };
295:                 this.bindRatioSliders('train-ratio', 'val-ratio', 'test-ratio');
296:             }
301:             modalSubmit.onclick = () => this.submitAddDataset();
302:         }, 100); // 短暂延迟确保 DOM 已加载

▶ 第 277 行:setTimeout 延时 100 毫秒再绑定——因为 modalBody.innerHTML = ... 是同步的,但保险起见等 DOM 稳定。⚠️ 这种"延时绑定"是常见 hack,说明代码对时序没有完全把握。
▶ 第 286~292 行:勾选"自动分割"复选框时显示/隐藏比例设置区(this.checked = 复选框当前是否选中)。
▶ 第 301 行:确定按钮 → 提交函数。

2.4 提交上传 submitAddDataset()(306~504 行)—— 本项目最完整的异步流程

流程:① 初始化拿 file_id → ② XHR 上传 → ③ 每秒轮询处理进度

306:     async submitAddDataset() {
307:         const form = document.getElementById('add-dataset-form');
308:         const nameInput = document.getElementById('dataset-name');
...
320:         const cancelButton = document.querySelector('.cancel-upload');
324:         if (!nameInput.value || !fileInput.files[0]) {
325:             showAlert('请填写必填字段', 'warning');
326:             return;
327:         }
329:         // 显示进度容器
330:         progressContainer.style.display = 'block';
331:         progressStatus.textContent = '初始化上传...';

▶ 第 306 行:async = 函数内可以用 await(暂停等待异步结果)。
▶ 第 307~322 行:一口气读 16 个表单元素(上传表单的每个字段)。
▶ 第 324~327 行:必填校验(名称 + 文件)。
▶ 第 330~332 行:显示进度条区域。

334:         try {
335:             // 第1步:初始化上传,获取文件ID
336:             const initFormData = new FormData();
337:             initFormData.append('name', nameInput.value);
338:             initFormData.append('file', fileInput.files[0]);
340:             const initResponse = await authenticatedFetch(`${API_URL}/datasets/upload-init`, {
341:                 method: 'POST',
342:                 body: initFormData
343:             });
345:             if (!initResponse.ok) {
346:                 throw new Error('初始化上传失败');
347:             }
349:             const initData = await initResponse.json();
350:             const fileId = initData.file_id;

▶ 第 336~338 行:FormData = 表单数据容器(自动设置 multipart 编码,能携带文件)。
▶ 第 340~343 行:await authenticatedFetch(...)——await = 等结果再往下走(之前章节用的是 .then,两种写法等价,await 更像顺序阅读)。
▶ 第 350 行:拿到服务器分配的 file_id(上传任务的身份证,后端 upload_manager 用它在内存里跟踪进度)。

352:             // 第2步:上传文件
356:             const formData = new FormData();
357:             formData.append('name', nameInput.value);
358:             formData.append('description', descriptionInput.value || '');
359:             formData.append('task', taskTypeSelect.value || 'detect');
360:             formData.append('file', fileInput.files[0]);
361:             formData.append('file_id', fileId);
364:             if (splitEnabled && splitEnabled.checked) {
365:                 formData.append('split_dataset_enabled', 'true');
366:                 formData.append('train_ratio', trainRatio ? trainRatio.value : '0.7');
...
370:             } else {
371:                 formData.append('split_dataset_enabled', 'false');
372:             }

▶ 第 356~372 行:正式上传的 FormData——所有表单字段 + 第 361 行的 file_id。

375:             // 创建XMLHttpRequest对象以支持上传进度
376:             const xhr = new XMLHttpRequest();
377:             xhr.open('POST', `${API_URL}/datasets/`, true);
378:             xhr.setRequestHeader('Authorization', getAuthToken());
380:             // 上传进度事件
381:             xhr.upload.onprogress = (e) => {
382:                 if (e.lengthComputable) {
383:                     const percentComplete = (e.loaded / e.total) * 100;
384:                     progressBarInner.style.width = percentComplete + '%';
385:                     progressBarInner.textContent = Math.round(percentComplete) + '%';

▶ 第 375~377 行:为什么不用 fetch 而用 XMLHttpRequest(XHR)?——因为 fetch 没有上传进度事件,XHR 有 xhr.upload.onprogress。这是老 API 反而适合的场景。
▶ 第 378 行:手动加认证头(XHR 不走 authenticatedFetch)。
▶ 第 381~385 行:上传过程中持续回调:e.loaded = 已传字节,e.total = 总字节 → 百分比 → 更新进度条宽度和文字。

397:             xhr.onload = () => {
398:                 if (xhr.status === 200 || xhr.status === 201) {
399:                     // 上传成功,开始轮询处理状态
400:                     statusCheckInterval = window.memoryManager.setInterval(async () => {
402:                         const statusResponse = await authenticatedFetch(`${API_URL}/datasets/upload-status/${fileId}`);
408:                         const statusData = await statusResponse.json();
411:                         progressBarInner.style.width = statusData.progress + '%';
412:                         progressBarInner.textContent = statusData.progress + '%';
413:                         progressStatus.textContent = statusData.message;

▶ 第 397~398 行:上传请求完成(HTTP 层面),200/201 = 成功。
▶ 第 400 行:开始轮询——setInterval(回调, 1000) = 每秒执行一次。注意用 window.memoryManager.setInterval 而不是原生 setInterval:memory-manager.js 会跟踪所有定时器,页面切换时统一清理(防止切页后定时器还在后台跑)。💡 这是本项目的一个优秀实践。
▶ 第 402 行:每秒问一次 GET /api/datasets/upload-status/{file_id}
▶ 第 411~413 行:用后端返回的进度(解压进度等)更新进度条。

426:                             if (statusData.status === 'uploading') {
427:                                 ...显示速度/剩余时间...
437:                             } else if (statusData.status === 'extracting') {
...
446:                             } else if (statusData.status === 'validating') {
...
453:                             } else if (statusData.status === 'completed') {
454:                                 window.memoryManager.clearInterval(statusCheckInterval);
455:                                 modal.hide();
456:                                 this.loadDatasets();
457:                             } else if (statusData.status === 'failed') {
458:                                 window.memoryManager.clearInterval(statusCheckInterval);
459:                                 progressStatus.textContent = '上传失败';
461:                                 showAlert('上传数据集失败: ' + (statusData.error || '未知错误'), 'danger');
462:                             }

▶ 第 426~462 行:状态机——根据后端返回的状态切换界面:

  • uploading:显示速度、剩余时间
  • extracting:提示"正在解压"
  • validating:提示"验证结构"
  • completed停轮询 → 关弹窗 → 刷新列表(成功收尾)
  • failed:停轮询 → 显示错误
497:             // 发送请求
498:             xhr.send(formData);
499:         } catch (error) {
500:             console.error('Error uploading dataset:', error);
501:             progressStatus.textContent = '上传失败';
502:             progressDetails.innerHTML = `错误: ${error.message}`;
503:             showAlert('上传数据集失败: ' + error.message, 'danger');
504:         }
505:     }

▶ 第 497~498 行:真正发出上传请求(前面所有都是准备)。
▶ 第 499~504 行:整个过程的异常兜底(await 抛出的错误都会进这里)。

2.5 比例滑块联动 bindRatioSliders()(518~578 行)

530:         const updateValues = () => {
531:             trainValue.textContent = `${Math.round(trainSlider.value * 100)}%`;
...
537:         const adjustRatios = (changedSlider) => {
538:             const train = parseFloat(trainSlider.value);
541:             const total = train + val + test;
543:             // 如果总和不为1,调整其他滑块
544:             if (Math.abs(total - 1.0) > 0.001) {
545:                 if (changedSlider === trainSlider) {
547:                     const remaining = 1.0 - train;
548:                     const ratio = val / (val + test);
549:                     valSlider.value = (remaining * ratio).toFixed(2);
550:                     testSlider.value = (remaining * (1 - ratio)).toFixed(2);

▶ 第 537~558 行:三个滑块总和恒为 1 的联动逻辑——拖其中一个,按比例调整另外两个。parseFloat = 字符串转小数;.toFixed(2) = 保留两位小数。
💡 这个函数体现了"前端尽力让用户输入合法",但后端还是独立校验了一次(datasets.py 第 262 行)——前后端双重校验是本项目的标准做法。

2.6 删除数据集 deleteDataset()(1706~1795 行)

1706:     deleteDataset(datasetId) {
1708:         const modal = this.createModal();
...
1721:         const title = document.createElement('h3');
1722:         title.textContent = '确认删除数据集';
...
1727:         confirmText.textContent = '您确定要删除这个数据集吗?如果有训练任务正在使用该数据集,则无法删除。';

▶ 第 1706~1729 行:不用全局 mainModal,而是用 createModal()(1884~1897 行)临时拼一个确认框(纯 JS 创建 div + 样式)。💡 为什么不用 showConfirm(03 篇)?——这里写的是旧代码,用了更原始的"自己造弹窗"方案。项目里两种方案并存,属于历史遗留。

1750:         confirmButton.onclick = () => {
1752:             confirmButton.disabled = true;
1753:             confirmButton.textContent = '删除中...';
1756:             authenticatedFetch(`${API_URL}/datasets/${datasetId}`, {
1757:                 method: 'DELETE'
1758:             })
1760:                 if (response.ok) {
1762:                     document.body.removeChild(modal);
1764:                     this.loadDatasets();
1766:                     showAlert('数据集删除成功', 'success');
1767:                 } else {
1769:                     return response.json().then(data => {
1770:                         throw new Error(data.detail || '删除数据集失败');
1771:                     });
1772:                 }

▶ 第 1750~1772 行:确认 → 发 DELETE /api/datasets/{id} → 成功:关弹窗 + 刷新列表 + 提示;失败:解析后端 detail 显示(比如"被训练任务占用"的错误就来自这里)。

2.7 分割数据集(1798~1881 行)

1798:     showSplitDatasetModal(datasetId) {
1803:         modalTitle.textContent = '分割数据集';
1806:         const template = document.getElementById('split-dataset-template');
1807:         modalBody.innerHTML = template.innerHTML;
1810:         modal.show();
1813:         this.bindRatioSliders('split-train-ratio', 'split-val-ratio', 'split-test-ratio');
1816:         modalSubmit.onclick = () => {
1817:             this.submitSplitDataset(datasetId);
1818:         };
1819:     }

▶ 第 1798~1819 行:打开分割弹窗(复用模板 + 绑定滑块 + 绑定提交)。

1822:     submitSplitDataset(datasetId) {
1835:         const data = {
1836:             train_ratio: trainRatio ? parseFloat(trainRatio.value) : 0.7,
...
1840:             mode: splitMode ? splitMode.value : 'from_train'
1841:         };
1844:         authenticatedFetch(`${API_URL}/datasets/${datasetId}/split`, {
1845:             method: 'POST',
...
1849:             body: JSON.stringify(data)
1850:         })

▶ 第 1835~1841 行:组装 JSON 请求体(比例、随机种子、分割模式)。
▶ 第 1844~1850 行:POST 到分割接口。

2.8 模块收尾(1906~1914 行)

1908: // 创建全局实例
1909: window.datasetManager = new DatasetManager();

▶ 第 1909 行:文件加载时立即创建实例挂到 window——main.js 的 loadPage switch 里 window.datasetManager.loadDatasets() 用的就是它。⚠️ 注意:这个文件自己就创建了实例,而 main.js 第 791 行又 new DatasetManager() 了一次并覆盖 window.datasetManager——等于创建了两个对象,第二个覆盖第一个。虽然都能用(类无状态),但这是项目里的小瑕疵,新手知道即可。

1911: // 导出供其他模块使用
1912: if (typeof module !== 'undefined' && module.exports) {
1913:     module.exports = DatasetManager;
1914: }

▶ 第 1912~1914 行:CommonJS 导出(供 Node 测试环境用;浏览器里 module 不存在,这行被跳过)。💡 这就是"一个文件兼容浏览器和 Node"的写法。


三、后端接口层:endpoints/datasets.py

3.1 导入与辅助模型(1~41 行)

1: from typing import List, Optional, Dict, Any
2: from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
3: from fastapi.responses import FileResponse, JSONResponse
4: from sqlalchemy.orm import Session
5: from pydantic import BaseModel
...
16: from app.db.session import get_db
17: from app.schemas.dataset import Dataset
18: from app.services import dataset_service
19: from app.services.upload_service import upload_manager
20: from app.crud import dataset
21: from app.core.config import settings

▶ 第 2 行:UploadFile/File = 文件上传类型;Form = 表单字段类型。凡是前端用 FormData 发的字段,后端必须用 Form(...) 声明(不是 Body!)——用错了会 422。
▶ 第 18~20 行:路由层只做"转发",真正逻辑在 service 和 crud。

25: class UploadResponse(BaseModel):
26:     file_id: str
27:     message: str
28: 
29: class UploadStatusResponse(BaseModel):
30:     file_id: str
31:     filename: str
...
41:     result_path: Optional[str] = None

▶ 第 25~41 行:两个接口的响应格式声明(upload-init 和 upload-status 的返回值结构)。

3.2 上传初始化 POST /api/datasets/upload-init(43~57 行)

43: @router.post("/upload-init", response_model=UploadResponse)
44: async def init_dataset_upload(
45:     name: str = Form(...),
46:     file: UploadFile = File(...)
47: ):
51:     # 创建上传任务
52:     file_id = upload_manager.create_upload(file.filename, file.size)
54:     return {
55:         "file_id": file_id,
56:         "message": "上传初始化成功,请开始上传文件"
57:     }

▶ 第 43 行:注意 /upload-init/{dataset_id} 之前定义——FastAPI 按定义顺序匹配路由,“upload-init” 如果被 /{dataset_id} 抢先匹配会变成 dataset_id=“upload-init”。所以静态路径要先声明(同类型还有 /import-local/import-coco 都在 /{dataset_id} 前面)。
▶ 第 52 行:upload_manager.create_upload(...) = 在内存里创建一个上传任务记录(upload_service.py 的 UploadManager,返回 file_id)。
▶ 第 54~57 行:返回 file_id 给前端——前端拿它去轮询进度

3.3 上传状态查询 GET /api/datasets/upload-status/{file_id}(59~68 行)

59: @router.get("/upload-status/{file_id}", response_model=UploadStatusResponse)
60: async def get_upload_status(file_id: str):
64:     status = upload_manager.get_status(file_id)
65:     if not status:
66:         raise HTTPException(status_code=404, detail="上传任务不存在")
68:     return status

▶ 第 64~68 行:从内存管理器中查任务状态(进度、阶段、速度等),不存在返回 404。

3.4 创建数据集 POST /api/datasets/(70~99 行)

70: @router.post("/", response_model=Dataset)
71: async def create_dataset(
72:     name: str = Form(...),
73:     description: Optional[str] = Form(None),
74:     task: str = Form("detect"),  # 任务类型
75:     file: UploadFile = File(...),
76:     file_id: Optional[str] = Form(None),
77:     split_dataset_enabled: bool = Form(False),
78:     train_ratio: float = Form(0.7),
79:     val_ratio: float = Form(0.15),
80:     test_ratio: float = Form(0.15),
81:     random_seed: int = Form(42),
82:     db: Session = Depends(get_db)
83: ):
87:     return await dataset_service.create_dataset(
88:         db=db, name=name, description=description, task=task,
89:         file=file, file_id=file_id,
90:         split_dataset_enabled=split_dataset_enabled,
91:         train_ratio=train_ratio, val_ratio=val_ratio,
92:         test_ratio=test_ratio, random_seed=random_seed
93:     )

▶ 第 70~82 行:9 个表单字段 + 数据库会话全部由 FastAPI 自动解析/注入。前端 FormData 里的每个字段都能对上(2.4 节)。
▶ 第 87~93 行:整个函数体就是一行——把参数转手给 service。这就是"路由层不写业务逻辑"的体现。

3.5 列表查询 GET /api/datasets/(101~111 行)

101: @router.get("/", response_model=List[Dataset])
102: def read_datasets(
103:     skip: int = 0,
104:     limit: int = 100,
105:     db: Session = Depends(get_db)
106: ):
110:     datasets = dataset_service.get_datasets(db, skip=skip, limit=limit)
111:     return datasets

▶ 第 103~104 行:分页参数(URL 里 ?skip=0&limit=100)。
▶ 第 110 行:调 service。

3.6 分割接口 POST /api/datasets/{dataset_id}/split(248~290 行)

248: @router.post("/{dataset_id}/split", response_model=Dict[str, Any])
249: def split_dataset_endpoint(
250:     dataset_id: str,
251:     train_ratio: float = Body(0.7),
...
255:     mode: str = Body("from_train"),
256:     db: Session = Depends(get_db)
257: ):
261:     # 检查比例总和是否为1
262:     if abs(train_ratio + val_ratio + test_ratio - 1.0) > 0.001:
263:         raise HTTPException(
264:             status_code=400,
265:             detail="分割比例总和必须为1.0",
266:         )
269:     # 获取数据集
270:     db_dataset = dataset_service.get_dataset(db, dataset_id=dataset_id)
272:     # 执行分割
273:     result = dataset_service.split_dataset(
274:         Path(db_dataset.path),
...
279:         mode=mode
280:     )
282:     # 更新数据库中的图像数量
283:     db_dataset = dataset.update(db, db_obj=db_dataset, obj_in={
284:         "image_count": result["total"]
285:     })
287:     return {"success": True, "message": "数据集分割成功", "result": result}

▶ 第 251~255 行:注意这里用 Body(...)(JSON 请求体)——前端 2.7 节发的是 JSON(Content-Type: application/json),所以后端用 Body 解析。Form vs Body 必须和前端对应
▶ 第 262~266 行:后端独立校验比例和(前端虽已保证,后端再验一次防绕过)。
▶ 第 273~280 行:调 service 的 split_dataset(操作文件系统)。
▶ 第 283~285 行:更新数据库 image_count。

3.7 删除接口 DELETE /api/datasets/{dataset_id}(292~300 行)

292: @router.delete("/{dataset_id}", response_model=Dataset)
293: def delete_dataset(
294:     dataset_id: str,
295:     db: Session = Depends(get_db)
296: ):
300:     return dataset_service.delete_dataset(db, dataset_id=dataset_id)

▶ 第 292~300 行:转发到 service。

3.8 其余接口速览

接口行号作用
GET /local-available113~118列出 datasets_import/ 里的目录(服务器数据集)
GET /browse-filesystem127~132浏览服务器文件系统(目录选择器)
GET /validate-external-directory136~141验证外部目录结构是否合法
POST /register-external150~177注册外部目录为数据集
GET /{dataset_id}179~188查单个数据集
POST /import-local190~217从 datasets_import 导入
POST /import-external219~246从外部路径导入
POST /import-coco302~337上传 COCO JSON 导入
POST /convert-coco339~457COCO JSON → YOLO txt 转换(生成目录结构 + classes.txt + dataset.yaml)
POST /open-folder459~486用系统文件管理器打开文件夹(Windows 用 os.startfile
GET /download-labels-zip488~535把标签目录打包 zip 下载(FileResponse + 响应后删除临时文件)

四、后端服务层:dataset_service.py(核心业务)

4.1 全局配置(1~59 行)

24: # 导入目录路径
25: IMPORT_DIR = Path("datasets_import")
26: 
27: # 允许浏览的根目录
28: ALLOWED_ROOTS = [
29:     Path("C:/"), ... Path("Z:/"), Path("/"), Path.home(),
54: ]
57: MAX_FILENAME_LENGTH = 200
59: ILLEGAL_CHARS_PATTERN = r'[<>:"/\\|?*\x00-\x1F]'

▶ 第 25 行:服务器目录导入的数据源(项目根目录的 datasets_import/)。
▶ 第 28~54 行:文件浏览器的"白名单根目录"(安全限制:只能在这些根下浏览)。
▶ 第 57~59 行:文件名长度上限 + Windows 非法字符正则(\x00-\x1F = 控制字符)。

4.2 文件名清洗 sanitize_filename()(61~77 行)

61: def sanitize_filename(filename: str) -> str:
66:     sanitized = re.sub(ILLEGAL_CHARS_PATTERN, '_', filename)
69:     base, ext = os.path.splitext(sanitized)
72:     if len(sanitized) > MAX_FILENAME_LENGTH:
74:         hashed_name = hashlib.md5(filename.encode('utf-8')).hexdigest()
75:         sanitized = f"{hashed_name}{ext}"
77:     return sanitized

▶ 第 66 行:re.sub(模式, 替换, 文本) = 把所有非法字符换成 _
▶ 第 69 行:os.path.splitext = 拆文件名和扩展名。
▶ 第 72~75 行:超长文件名用 MD5 哈希替换(哈希固定 32 字符,长度可控)。💡 这个函数解决的是 Windows 260 字符路径限制问题。

4.3 结构整理 process_dataset_structure()(119~172 行)

131:     os.makedirs(train_images_dir, exist_ok=True)
...6 个目录全部创建)
139:     root_images_dir = dataset_dir / "images"
140:     root_labels_dir = dataset_dir / "labels"
141:     if root_images_dir.exists():
145:         if root_images and not train_images_existing:
146:             for img_file in root_images:
147:                 shutil.copy2(str(img_file), str(train_images_dir / img_file.name))
148:                 label_file = root_labels_dir / (img_file.stem + ".txt")
149:                 if label_file.exists():
150:                     shutil.copy2(str(label_file), str(train_labels_dir / label_file.name))

▶ 第 131~136 行:确保 train/val/test 的 images+labels 六个目录都存在(容错:用户 ZIP 结构不完整也能自动补齐)。
▶ 第 139~150 行:如果用户把图片放在根目录 images/(而不是 train/images/),自动迁移到 train/images/(图 + 同名标签一起搬)。

153:     # 如果 val/images/ 为空但 train/images/ 有图片,复制一份到 val(YOLO 要求 val 非空)
154:     val_images_existing = list(val_images_dir.glob("*.*"))
155:     if not val_images_existing:
156:         train_images_for_val = list(train_images_dir.glob("*.*"))
157:         for img_file in train_images_for_val:
158:             shutil.copy2(str(img_file), str(val_images_dir / img_file.name))

▶ 第 153~160 行:验证集为空 → 从训练集复制一份过去(YOLO 训练必须有验证集,否则报错)。这就是为什么"上传一个只有 train 的数据集也能训练"。

4.4 分割数据集 split_dataset()(238~528 行)—— 两种模式

模式一:from_train(默认)——从训练集里抽出 val 和 test(292~335 行)

292:     if mode == "from_train":
294:         train_images = get_image_files(train_images_dir)
299:         total_images = len(train_images)
308:         num_val = int(total_images * val_ratio / (1 - test_ratio))
309:         num_test = int(total_images * test_ratio / (1 - test_ratio))
312:         num_val = min(num_val, total_images - 1)  # 至少保留一张图片在训练集
313:         num_test = min(num_test, total_images - num_val - 1)

▶ 第 308~309 行:归一化计算——因为只从 train 抽,val/test 比例要按"去掉 test 后"重新算(val/(1-test))。
▶ 第 312~313 行:边界保护:训练集至少留 1 张,test 也不能把 train 掏空。

318:         if num_val + num_test > 0:
319:             images_to_move = random.sample(train_images, num_val + num_test)
320:             val_images = images_to_move[:num_val]
321:             test_images = images_to_move[num_val:]

▶ 第 319 行:random.sample(列表, n) = 无放回随机抽 n 个(每张只抽一次)。第 263 行的 random.seed(random_seed) 保证可复现(同种子每次结果一样)。
▶ 第 320~321 行:切片分配:前 num_val 个去 val,其余去 test。

325:             for img_path in val_images:
326:                 if move_image_and_label(img_path, val_images_dir, val_labels_dir):
327:                     val_success += 1

▶ 第 326 行:调 move_image_and_label()(174~236 行)搬图片+标签(先复制到目标、成功后才删源文件——安全搬迁模式)。

模式二:redistribute——全部打乱重新分配(337~509 行)

346:         all_images = []
347:         all_images.extend([(img, "train") for img in train_images])
348:         all_images.extend([(img, "val") for img in val_images])
349:         all_images.extend([(img, "test") for img in test_images])
352:         random.shuffle(all_images)
365:         num_train = max(1, int(total_images * train_ratio))
366:         num_val = max(1, int(total_images * val_ratio))

▶ 第 347~349 行:列表推导式 [(img, "train") for img in train_images] = 生成"(图片, 来源)"元组列表,记录每张图来自哪个集合。
▶ 第 352 行:random.shuffle = 原地打乱。
▶ 第 365~366 行:按比例算数量,max(1, ...) = 每个集合至少 1 张。
▶ 后续流程:全部复制到临时目录 → 清空六个目录 → 按新分配搬回去 → 删临时目录(先复制到 temp 验证完整,再清空,避免搬一半失败导致数据丢失——安全的换血流程)。

512:     # 更新dataset.yaml
513:     update_dataset_yaml(dataset_dir)

▶ 第 513 行:分割完更新 yaml(添加 test 路径,530~547 行)。

4.5 创建数据集 create_dataset()(549~944 行)—— 整个模块的心脏

第 1 步:查重 + 建目录(565~578 行)

566:     db_dataset = dataset.get_by_name(db, name=name)
567:     if db_dataset:
568:         if file_id:
569:             upload_manager.set_failed(file_id, "Dataset with this name already exists")
570:         raise HTTPException(status_code=400, detail="Dataset with this name already exists")
576:     dataset_id = str(uuid.uuid4())
577:     dataset_dir = settings.DATASETS_DIR / dataset_id
578:     os.makedirs(dataset_dir, exist_ok=True)

▶ 第 566~573 行:重名检查(通过 crud 的 get_by_name);失败时更新上传状态为 failed(前端轮询能看到)。
▶ 第 576~578 行:数据集目录用 UUID 命名app/static/datasets/9f2c.../)——永远不会撞名。

第 2 步:保存 ZIP(580~604 行)

584:     if file_id:
590:         chunk_size = 4 * 1024 * 1024  # 4MB chunks
591:         total_size = 0
593:         with open(upload_path, "wb") as buffer:
594:             while True:
595:                 chunk = await file.read(chunk_size)
596:                 if not chunk:
597:                     break
598:                 buffer.write(chunk)
599:                 total_size += len(chunk)
600:                 upload_manager.update_progress(file_id, total_size)
601:     else:
603:         with open(upload_path, "wb") as buffer:
604:             shutil.copyfileobj(file.file, buffer)

▶ 第 590~600 行:分块写入(每块 4MB)并更新进度。await file.read(块大小) 异步读,读到空(not chunk)结束。💡 进度条这里更新的是"已写字节数"(uploading 阶段)。
▶ 第 601~604 行:没有 file_id(老流程)就直接整体复制。

第 3 步:解压 + 修复顶层目录(606~714 行)

613:         with zipfile.ZipFile(upload_path, 'r') as zip_ref:
615:             file_list = zip_ref.namelist()
616:             total_files = len(file_list)
620:             batch_size = 100
621:             for i in range(0, total_files, batch_size):
622:                 batch = file_list[i:i + batch_size]
623:                 zip_ref.extractall(dataset_dir, members=batch)
624:                 extracted_files += len(batch)
628:                     progress = min(99, int((extracted_files / total_files) * 100))
632:                     upload_manager.update_progress(file_id, int(file.size * (progress / 100)))

▶ 第 620~624 行:分批解压(每批 100 个文件,大 zip 不卡内存)。
▶ 第 628~632 行:解压进度映射回"字节进度"(进度条不跳变)。

639:         extracted_items_at_root = list(dataset_dir.iterdir())
645:         potential_redundant_dirs = [
646:             item for item in extracted_items_at_root
647:             if item.is_dir()
648:             and item.name not in ["train", "val", "test", "test_images", "train_images", "val_images"]
649:             and item.name != "__MACOSX"
650:             and not item.name.startswith(".")
651:         ]
655:         if len(potential_redundant_dirs) == 1:
656:             redundant_top_dir = potential_redundant_dirs[0]
657:             print(f"Found a single potential redundant top-level directory: {redundant_top_dir.name}...")

▶ 第 645~714 行:处理"套娃 ZIP"——很多用户打包时把整个数据集文件夹又包了一层(dataset.zip 解压出 dataset/train/... 而不是直接 train/...)。这段检测"根目录下只有一个非标准目录",就把它内容上提一层。
▶ 第 648 行:排除标准目录名;第 649 行:排除 macOS 压缩产生的 __MACOSX;第 650 行:排除隐藏目录。
▶ 处理方式:先移到临时目录 → 清空根 → 移回来(防止覆盖冲突)。

第 4 步:验证结构 + 读类别(717~789 行)

737:         if not train_images_dir.exists():
739:             os.makedirs(train_images_dir, exist_ok=True)
748:         if not classes_file.exists():
751:             with open(classes_file, "w", encoding="utf-8") as f:
752:                 f.write("object\n")  # 默认类别

▶ 第 737~745 行:目录不存在就创建(容错)。
▶ 第 748~752 行:classes.txt 缺失 → 生成默认的 “object”(一个类别也能用)。

757:             with open(classes_file, "r", encoding="utf-8") as f:
758:                 classes = [line.strip() for line in f.readlines()]
759:         except UnicodeDecodeError:
762:                 with open(classes_file, "r", encoding="latin-1") as f:
763:                     classes = [line.strip() for line in f.readlines()]

▶ 第 757~763 行:编码降级——UTF-8 读失败换 latin-1(任何字节都能读)再失败就重建。line.strip() = 去掉每行首尾空白;f.readlines() = 按行读成列表。结果:["person", "car", ...]

第 5 步:结构整理 + 可选分割(790~814 行)

791:         structure_info = process_dataset_structure(dataset_dir)
794:         if split_dataset_enabled:
796:             if abs(train_ratio + val_ratio + test_ratio - 1.0) > 0.001:
797:                 raise HTTPException(status_code=400, detail="分割比例总和必须为1.0")
802:             split_result = split_dataset(dataset_dir, ..., mode="redistribute")
812:             image_count = split_result["total"]
813:         else:
814:             image_count = structure_info["total_count"]

▶ 第 791 行:先自动迁移根 images/ 和补 val(4.3 节)。
▶ 第 794~814 行:勾选了"自动分割" → 调 split_dataset(redistribute 模式);否则用统计数量。

第 6 步:写 dataset.yaml(816~838 行)

817:         dataset_yaml = {
818:             "path": str(dataset_dir),
819:             "train": "train/images",
820:             "val": "val/images",
821:             "test": "test/images",
822:             "nc": len(classes),
823:             "names": classes
824:         }
828:                 import yaml
829:                 yaml.dump(dataset_yaml, f, default_flow_style=False)

▶ 第 817~824 行:生成 Ultralytics 训练要用的 dataset.yaml(path/train/val/nc/names)。这就是为什么后面训练只传数据集 ID,不用手动写 yaml。
▶ 第 828~829 行:yaml.dump 把字典序列化成 YAML 文件。default_flow_style=False = 用块状格式(不是一行)。

第 7 步:入库 + 清理(840~944 行)

845:         obj_in_data = {
846:             "name": name, "description": description or "",
847:             "task": task,
849:             "path": str(dataset_dir),
850:             "classes": classes,
851:             "image_count": image_count,
852:             "status": "available"
853:         }
856:         db_dataset = dataset.create_with_fields(db, obj_in=obj_in_data)
860:         if file_id:
861:             upload_manager.set_completed(file_id, str(dataset_dir))
862:         return db_dataset

▶ 第 845~853 行:组装数据库记录字段。⚠️ 注意 owner_id 没在这里设置(多用户归属问题,代码里没完整实现——admin 创建的数据归 admin,见 init_db 的迁移逻辑)。
▶ 第 856 行:create_with_fields = crud/base.py 第 98 行的方法:直接把字典存成一行(比 schemas 校验更快,但少了类型检查)。
▶ 第 860~861 行:上传状态标记完成。

864:     except HTTPException as e:
867:         if dataset_dir.exists():
868:             shutil.rmtree(dataset_dir)
869:         if upload_path.exists():
870:             os.remove(upload_path)
873:         if file_id:
874:             upload_manager.set_failed(file_id, str(e.detail))
877:         raise HTTPException(
878:             status_code=e.status_code,
879:             detail=f"{e.detail}\n\n请确保上传的数据集符合以下结构:\n- train/images/: 训练图像目录\n- val/images/: 验证图像目录\n- classes.txt: 类别列表文件",
880:         )
941:     finally:
942:         # Remove the ZIP file
943:         if upload_path.exists():
944:             os.remove(upload_path)

▶ 第 864~940 行:三路异常处理(HTTPException / UnicodeDecodeError / 其他),共同点:清理半成品(删目录、删 zip、标记失败)再抛出友好错误。
▶ 第 877~879 行:错误信息里贴心附上"正确的目录结构说明"。
▶ 第 941~944 行:finally = 无论成败最后删掉上传的 ZIP(防止磁盘堆积)。

4.6 列表查询 get_datasets()(958~986 行)

962:     datasets_list = dataset.get_multi(db, skip=skip, limit=limit)
965:     for ds in datasets_list:
967:             dataset_path = Path(ds.path)
968:             train_images_dir = dataset_path / "train" / "images"
972:             train_count = len(list(train_images_dir.glob("*.*"))) if train_images_dir.exists() else 0
977:             ds.train_count = train_count

▶ 第 962 行:查数据库全部数据集。
▶ 第 965~984 行:逐个数目录里的图片,把数量临时塞到对象属性上(不入库)——这就是前端表格里 train/val/test 数量的来源。💡 每次列表查询都扫描磁盘,数据集多了会慢——设计上可以缓存,但项目选择了简单方案。

4.7 删除 delete_dataset()(1549~1589 行)

1553:     db_dataset = dataset.get(db, id=dataset_id)
1554:     if not db_dataset:
1555:         raise HTTPException(status_code=404, detail="Dataset not found")
1561:     from app.crud import training_task
1564:     tasks = db.query(training_task.model).filter(training_task.model.dataset_id == dataset_id).all()
1566:     if tasks:
1567:         task_names = [task.name for task in tasks]
1568:         raise HTTPException(status_code=400, detail=f"无法删除数据集,因为它被以下训练任务使用: ...")

▶ 第 1564 行:引用保护——查 training_tasks 表有没有任务引用这个数据集(README 的核心特性之一)。被引用 → 400 拒绝删除。
▶ 第 1574~1586 行:is_external 的数据集只删数据库记录、不动磁盘目录(外部目录可能正在被其他系统使用)。
▶ 第 1588~1589 行:普通数据集:删磁盘目录 + 删数据库记录。


五、数据层:crud_dataset.py + base.py

5.1 app/crud/base.py(137 行)—— 通用增删改查模板

9: ModelType = TypeVar("ModelType", bound=Base)
10: CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
11: UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)

▶ 第 9~11 行:泛型——“类型占位符”。CRUDBase[Dataset, DatasetCreate, DatasetUpdate] = 这套方法专门服务"Dataset 模型 + DatasetCreate/DatasetUpdate 模式"。写一次通用代码,所有模型都能复用。

13: class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
14:     def __init__(self, model: Type[ModelType]):
23:         self.model = model
25:     def get(self, db: Session, id: Any) -> Optional[ModelType]:
26:         return db.query(self.model).filter(self.model.id == id).first()

▶ 第 23 行:记住"服务哪个模型"(如 Dataset 类)。
▶ 第 25~26 行:按 id 查一行(SELECT * FROM datasets WHERE id=? LIMIT 1)。

90:     def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
91:         obj_in_data = jsonable_encoder(obj_in)
92:         db_obj = self.model(**obj_in_data)
93:         db.add(db_obj)
94:         db.commit()
95:         db.refresh(db_obj)
96:         return db_obj

▶ 第 91 行:jsonable_encoder = 把 Pydantic 对象转成普通字典。
▶ 第 92 行:self.model(**obj_in_data) = 把字典展开成构造参数创建对象(** 展开语法)。
▶ 第 93~95 行:三段式提交(add → commit → refresh,02 篇讲过)。

113:     def update(self, db, *, db_obj, obj_in):
120:         obj_data = jsonable_encoder(db_obj)
121:         if isinstance(obj_in, dict):
122:             update_data = obj_in
123:         else:
124:             update_data = obj_in.dict(exclude_unset=True)
125:         for field in obj_data:
126:             if field in update_data:
127:                 setattr(db_obj, field, update_data[field])
128:         db.add(db_obj)
129:         db.commit()
130:         db.refresh(db_obj)
131:         return db_obj

▶ 第 121~124 行:支持两种输入:字典或 Pydantic 对象。
▶ 第 125~127 行:遍历旧对象字段,凡是新数据里有的就覆盖(部分更新)。

5.2 app/crud/crud_dataset.py(21 行)—— 数据集专属查询

8: class CRUDDataset(CRUDBase[Dataset, DatasetCreate, DatasetUpdate]):
9:     def get_by_name(self, db: Session, *, name: str, user_id=None, user_role=None) -> Optional[Dataset]:
10:         query = db.query(Dataset).filter(Dataset.name == name)
11:         if user_role != "admin" and user_id is not None:
12:             query = query.filter(Dataset.owner_id == user_id)
13:         return query.first()

▶ 第 8 行:继承通用 CRUDBase(自动获得 get/create/update/remove)。
▶ 第 10~13 行:按名字查重,带多用户隔离:非管理员只能看到自己的(owner_id 过滤)。

15:     def get_available(self, db, *, skip=0, limit=100, user_id=None, user_role=None):
16:         query = db.query(Dataset).filter(Dataset.status == "available")
17:         if user_role != "admin" and user_id is not None:
18:             query = query.filter(Dataset.owner_id == user_id)
19:         return query.offset(skip).limit(limit).all()

▶ 第 15~19 行:只查"可用"状态的数据集(训练下拉框用它,06 篇会见到)。

21: dataset = CRUDDataset(Dataset)

▶ 第 21 行:创建模块级单例——其他文件 from app.crud import dataset 拿到它,调用 dataset.get(...) 等。


六、数据图纸:models/dataset.py + schemas/dataset.py

6.1 app/models/dataset.py(30 行)

9: class Dataset(Base):
10:     __tablename__ = "datasets"
12:     id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
13:     name = Column(String, nullable=False, index=True)
14:     description = Column(Text, nullable=True)
15:     path = Column(String, nullable=False)
16:     classes = Column(JSON, nullable=False)
17:     image_count = Column(Integer, default=0)
18:     created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
...
20:     status = Column(String, default="processing")  # processing, available, error
21:     is_external = Column(Boolean, default=False)
22:     task = Column(String, default="detect")
23:     train_count = Column(Integer, default=0)
24:     val_count = Column(Integer, default=0)
25:     test_count = Column(Integer, default=0)
26:     owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
27:     
28:     # 关联关系
29:     annotation_projects = relationship("AnnotationProject", back_populates="dataset")
30:     owner = relationship("User")

▶ 第 12 行:主键是 UUID(用户表是自增 int)——数据集用 UUID 的好处:不暴露序号、无法猜测他人 ID。
▶ 第 16 行:JSON 类型列 = 直接存 Python 列表(PostgreSQL 的 JSONB)。
▶ 第 20 行:状态字段(processing → available / error)。
▶ 第 26 行:ForeignKey("users.id") = 外键:owner_id 必须指向 users 表存在的 id(数据库层保证引用有效)。
▶ 第 29~30 行:relationship = ORM 关联:dataset.owner 直接拿到 User 对象;dataset.annotation_projects 拿到关联的标注项目列表(不定义也行,定义了查询方便)。

6.2 app/schemas/dataset.py(39 行)

6: class DatasetBase(BaseModel):
7:     name: str
8:     description: Optional[str] = None
10: class DatasetCreate(DatasetBase):
11:     path: str
12:     classes: List[str]
13:     image_count: int = 0
14:     task: str = "detect"
19: class DatasetInDBBase(DatasetBase):
20:     id: UUID
21:     path: str
...
29:     status: str
30:     task: str = "detect"
35: class Dataset(DatasetInDBBase):
36:     pass

▶ 第 6~8 行:公共字段(name 必填,description 可选)。
▶ 第 10~14 行:创建时用的模式(含 path/classes 等)。
▶ 第 19~33 行:响应模式——前端拿到的数据集长这样(response_model=Dataset 会过滤掉多余字段,比如 owner_id 不会发给前端)。
▶ 第 35~36 行:pass = 空继承(语义化命名,内容继承父类)。


七、全链路走一遍(上传 ZIP 数据集)

1. 前端:点"上传ZIP数据集" → showAddDatasetModal() → 弹窗显示表单(模板)
2. 填名称、选文件、选分割 → 点确定 → submitAddDataset()
3. 第1步:POST /api/datasets/upload-init(FormData: name+file)
   → upload_manager.create_upload() → 返回 file_id
4. 第2步:XHR 上传 POST /api/datasets/(FormData: 全部字段 + file_id)
   → upload_manager.update_progress()(上传中进度)
5. 第3步:前端每秒轮询 GET /api/datasets/upload-status/{file_id}
   → create_dataset() 服务端继续:
     a. 查重名 → 建 UUID 目录 → 分块保存 ZIP
     b. 解压(分批 + 进度)→ 处理套娃目录
     c. 清理非法文件名 → 补目录 → 读 classes.txt(编码降级)
     d. 迁移根 images/ → 补 val → (可选)自动分割
     e. 生成 dataset.yaml → 入库(status=available)
     f. finally 删除 ZIP
   → upload_manager 状态机:uploading → extracting → validating → completed
6. 前端轮询到 completed → 关弹窗 → loadDatasets() 重新渲染列表
7. 列表 GET /api/datasets/ → get_datasets() 数目录图片 → JSON → 表格显示

八、验证任务(动手做)

  1. 准备测试 ZIP:建一个 mydata/train/images/ 放 5 张图片 + 一个 classes.txt(写 cat),压缩成 ZIP,上传到平台,观察:
    • F12 Network 里依次出现的三个请求(upload-init / datasets / upload-status 轮询)
    • 平台自动生成的 val/images(复制了 train 的图片)
    • app/static/datasets/<uuid>/ 下的目录结构和 dataset.yaml 内容
  2. 套娃测试:把整个 mydata 文件夹再包一层(mydata.zip 里是 mydata/ 目录)上传——看服务端日志里 “Found a single potential redundant top-level directory”
  3. 删不掉测试:新建一个训练任务引用这个数据集(06 篇后再试也行),然后删数据集——看 400 错误信息
  4. F12 查看轮询:上传大文件时观察 Network 里每秒一次的上传状态请求,看状态值从 uploading → extracting → validating → completed 的变化
  5. 代码实验:把 app/db/session.pyecho=True(02 篇),上传一次数据集,观察控制台打印的所有 SQL(能看到 SELECT、INSERT datasets 的全过程)

九、本篇小结

关键代码一句话职责
前端submitAddDataset()两步上传 + XHR 进度 + 轮询状态机
路由datasets.py12 个接口,全是转发
服务create_dataset()查重→存盘→解压→修结构→读类别→yaml→入库→清理
服务split_dataset()两种分割模式 + 安全换血流程
服务delete_dataset()引用保护 + 外部目录不动
数据CRUDBase泛型增删改查模板(所有模块复用)
图纸models/dataset.pyUUID 主键 + JSON 列 + 外键
翻译schemas/dataset.py响应过滤(不暴露 owner_id)

学到的通用模式

  1. 上传三部曲:初始化(拿 id)→ 上传(XHR 带进度)→ 轮询状态(每秒)
  2. 前后端双校验:滑块保证比例和为 1(前端)+ 接口再验(后端)
  3. 幂等与容错:目录缺失自动补、编码失败自动降级、套娃目录自动修
  4. 安全删除:先复制后删除、finally 清理、引用检查
  5. 进度状态机:uploading → extracting → validating → completed/failed

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/liutao841204/article/details/163979487

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--