news 2026/9/26 2:58:06

基于YOLOv5与ResNet18的骨龄检测双模型方案:从数据到部署

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于YOLOv5与ResNet18的骨龄检测双模型方案:从数据到部署

简介:本资源面向计算机视觉方向的本科与研究生毕业设计需求,提供一套基于YOLOv5与ResNet18联合实现的骨龄检测完整工程,适合需要完成高分毕设、课程设计或医学影像入门实践的同学。项目将目标检测与图像分类串联,先定位手骨关键区域,再回归骨龄等级,可作为深度学习落地案例参考。压缩包共200个文件,约717.06MB,包含69个Python源码、53个YAML配置、18个PTH权重文件,以及Dockerfile、Shell脚本、Markdown说明与Jupyter笔记等,覆盖训练、推理、部署与文档环节。目前已有438人学习下载。资源内含已调试通过的代码、预训练模型与配套数据集,读者可据此复现实验流程、理解网络结构与数据组织方式,并在此基础上调整超参或替换骨干网络,快速形成可展示、可答辩的毕设成果。

1. 骨龄检测这套双模型方案,到底解决了什么实际问题

拍一张左手腕 X 光片,医生要对着 TW3 或 CHN 骨龄标准逐块腕骨比对,数出 20 多个骨化中心的发育等级,再查表折算成骨龄。一个有经验的放射科医生看一张片子要 3 到 5 分钟,基层医院一天积压几十张就是灾难。更麻烦的是不同医生判读一致性差,同一张片子两个人给出的骨龄可能差半岁到一岁。基于 yolov5 + ResNet18 实现的骨龄检测方案,思路就是把这件事拆成两段:yolov5 负责在整张手腕片里把感兴趣区域(ROI)框出来,ResNet18 负责对每个区域做分类或回归,最后把各区域结果融合成骨龄值。这套组合之所以在毕设和工程落地里反复出现,是因为它把目标检测和图像分类两个成熟模块拼在一起,数据集要求比端到端回归低,训练也更容易收敛。适合谁?手里有骨龄 X 光数据集、想快速跑通一条检测加分类流水线的同学和工程师,尤其是需要一份能复现、能改、能写进论文的完整代码加模型加数据集的场景。下面按数据准备、yolov5 检测、ResNet18 分类、融合出骨龄、避坑、进阶验证的顺序讲透。

2. 数据集怎么整理:从原始 X 光片到 yolov5 与 ResNet18 双输入

2.1 骨龄数据集的两套标注体系

骨龄数据集的标注和普通目标检测数据集不一样。普通检测只要框出物体,骨龄还要给每个骨化中心打发育等级。常见做法是两套标注并行:一套是 yolov5 用的边界框标注,每个腕骨、掌骨、指骨一个框,类别可以按骨骼名称分,也可以统一成“骨化中心”一类;另一套是 ResNet18 用的分类标签,每个框裁剪出来的小图对应一个发育等级(比如 0 到 8 级)或者对应的骨龄贡献值。如果原始数据集只有整片骨龄值没有逐骨标注,那就只能走整图回归路线,ResNet18 直接吃整张片,但精度和可解释性都会掉一截。我一般会先确认数据集里有没有逐骨标注文件,有的话优先做双模型,没有就退化成单模型回归。

整理时先把所有 X 光片统一成灰度图,位深 8 位或 16 位都行,但训练前要归一化到 0 到 1。分辨率不要无脑放大,手腕片本身细节有限,短边保持在 512 到 768 之间比较稳,太大显存吃不消,太小骨化中心糊成一团。文件名建议保留患者 ID 和左右手信息,后面做数据划分时按患者划分,不能按图片随机划分,否则同一个人不同角度的片子会同时出现在训练集和验证集,指标虚高。

2.2 用脚本把标注转成 yolov5 和 ResNet18 都能吃的格式

假设原始标注是 CSV,每行是image_name, x_min, y_min, x_max, y_max, class_id, grade,下面这个脚本把它拆成 yolov5 的 txt 标签和 ResNet18 的分类文件夹结构。

import os import csv import cv2 import numpy as np from sklearn.model_selection import train_test_split # 原始数据路径 img_dir = "raw_images" csv_path = "annotations.csv" out_yolo = "yolo_dataset" out_cls = "cls_dataset" os.makedirs(f"{out_yolo}/images/train", exist_ok=True) os.makedirs(f"{out_yolo}/images/val", exist_ok=True) os.makedirs(f"{out_yolo}/labels/train", exist_ok=True) os.makedirs(f"{out_yolo}/labels/val", exist_ok=True) records = [] with open(csv_path, "r") as f: reader = csv.DictReader(f) for row in reader: records.append(row) # 按患者 ID 划分,这里假设 image_name 前缀是患者 ID patient_ids = list(set([r["image_name"].split("_")[0] for r in records])) train_ids, val_ids = train_test_split(patient_ids, test_size=0.2, random_state=42) def convert(size, box): dw, dh = 1.0 / size[0], 1.0 / size[1] x = (box[0] + box[1]) / 2.0 y = (box[2] + box[3]) / 2.0 w = box[1] - box[0] h = box[3] - box[2] return x * dw, y * dh, w * dw, h * dh for r in records: img_name = r["image_name"] pid = img_name.split("_")[0] split = "train" if pid in train_ids else "val" img_path = os.path.join(img_dir, img_name) img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: continue h, w = img.shape # 写 yolov5 标签 x, y, bw, bh = convert((w, h), (float(r["x_min"]), float(r["x_max"]), float(r["y_min"]), float(r["y_max"]))) label_path = os.path.join(out_yolo, "labels", split, img_name.replace(".png", ".txt")) with open(label_path, "a") as lf: lf.write(f"{r['class_id']} {x:.6f} {y:.6f} {bw:.6f} {bh:.6f}\n") # 复制图片到 yolo 目录 cv2.imwrite(os.path.join(out_yolo, "images", split, img_name), img) # 裁剪 ROI 到分类数据集 roi = img[int(r["y_min"]):int(r["y_max"]), int(r["x_min"]):int(r["x_max"])] if roi.size == 0: continue cls_dir = os.path.join(out_cls, split, str(r["grade"])) os.makedirs(cls_dir, exist_ok=True) cv2.imwrite(os.path.join(cls_dir, img_name.replace(".png", f"_{r['class_id']}.png")), roi)

这段脚本做了三件事:按患者划分训练验证集、生成 yolov5 需要的归一化中心点加宽高标签、把每个标注框裁剪成小图按发育等级存到分类文件夹。参数上注意test_size=0.2是验证集比例,患者数量少的时候可以调到 0.15 到 0.25 之间;class_id如果统一成 0,yolov5 就只学“骨化中心”一个类,后面 ResNet18 再细分等级,这样检测任务更简单。裁剪 ROI 时如果框贴边,roi.size可能为 0,脚本里加了跳过,实际跑的时候要检查有多少被跳过,超过 5% 就得回头看标注是不是越界了。

2.3 数据增强的边界:骨龄片不能乱翻转

骨龄片有左右手之分,也有解剖方向。水平翻转会把左手变成右手,如果模型没见过对侧手,翻转后语义就错了。垂直翻转更不行,手腕倒过来解剖结构完全不对。我一般只做小角度旋转(正负 10 度以内)、亮度对比度微调、轻微缩放和平移。yolov5 自带的 mosaic 增强在骨龄片上要慎用,四张图拼一起会让骨化中心上下文混乱,mosaic 概率建议从默认 1.0 降到 0.3 到 0.5。ResNet18 那边的分类增强同样只做温和的仿射变换,不要用 RandAugment 里那些剪切和颜色抖动,灰度片颜色抖动没意义。

3. yolov5 检测骨化中心:环境配置与训练参数怎么定

3.1 环境配置与最小可跑命令

yolov5 对环境不算挑剔,但版本要对齐。常见做法是 conda 建一个 Python 3.8 到 3.10 的环境,PyTorch 选 1.12 到 2.0 之间,CUDA 版本跟显卡驱动匹配。下面是一套我常用的安装流程。

conda create -n bone_age python=3.9 -y conda activate bone_age # 按自己 CUDA 版本装 torch,这里以 cu118 为例 pip install torch==2.0.1 torchvision==0.15.2 --index-url https://download.pytorch.org/whl/cu118 git clone https://github.com/ultralytics/yolov5.git cd yolov5 pip install -r requirements.txt

装完先跑一次python detect.py --source data/images --weights yolov5s.pt确认环境没问题。然后准备数据配置文件bone_age.yaml:

path: ../yolo_dataset train: images/train val: images/val nc: 1 names: ['ossification_center']

nc是类别数,如果按骨骼名称分类就改成实际类别数,names对应写全。数据路径用相对路径时注意path是相对于 yolov5 根目录还是 yaml 文件所在目录,不同版本行为有差异,跑之前用python train.py --data bone_age.yaml --dry-run之类的检查一下,或者直接看打印出来的数据集统计。

3.2 训练参数:骨龄检测的 batch、学习率和 anchor

骨龄片数量通常不大,几千张算多的。yolov5s 或 yolov5m 足够,n 太小精度不够,l 和 x 容易过拟合。启动训练:

python train.py \ --data bone_age.yaml \ --weights yolov5s.pt \ --img 640 \ --batch 16 \ --epochs 150 \ --lr0 0.01 \ --lrf 0.01 \ --mosaic 0.3 \ --degrees 10 \ --flipud 0.0 \ --fliplr 0.0 \ --project runs/bone_age \ --name exp1

参数逐个说:--img 640是输入分辨率,骨化中心小的话可以提到 768,但显存翻倍;--batch 16在 8G 显存上跑 640 分辨率差不多,显存小就降到 8 并开--accumulate;--lr0 0.01是初始学习率,小数据集可以降到 0.005 到 0.008;--lrf 0.01是最终学习率系数,配合余弦退火;--mosaic 0.3降低拼接概率;--degrees 10限制旋转角度;--flipud 0.0和--fliplr 0.0关掉上下和左右翻转,骨龄片不能翻。anchor 方面,yolov5 默认 anchor 是基于 COCO 的,骨化中心尺寸偏小且集中,建议用python utils/autanchor.py在骨龄数据集上重新聚类一遍,把 anchor 换成适合小目标的尺寸,这一步对召回率提升很明显。

训练过程中重点看metrics/mAP_0.5和metrics/mAP_0.5:0.95,骨龄检测更关心高 IoU 下的精度,因为框不准后面分类裁剪就会带进背景。如果 mAP 卡在 0.6 上不去,先检查标注框是不是把整个手腕都框进去了,骨化中心应该只框骨化中心本身,不要框太大。另外val集 loss 如果比train高很多,说明过拟合,加 dropout 或者减模型规模。

3.3 推理与 ROI 导出

训练完用best.pt跑推理,把每个框裁剪出来给 ResNet18。下面这段代码批量导出 ROI 并保留坐标信息。

import torch import cv2 import os import numpy as np model = torch.hub.load('ultralytics/yolov5', 'custom', path='runs/bone_age/exp1/weights/best.pt') model.conf = 0.25 # 置信度阈值 model.iou = 0.45 # NMS IoU 阈值 img_dir = "yolo_dataset/images/val" save_dir = "roi_output" os.makedirs(save_dir, exist_ok=True) for img_name in os.listdir(img_dir): img_path = os.path.join(img_dir, img_name) img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) results = model(img_path, size=640) boxes = results.xyxy[0].cpu().numpy() # x1,y1,x2,y2,conf,cls for i, box in enumerate(boxes): x1, y1, x2, y2 = map(int, box[:4]) roi = img[max(0, y1):y2, max(0, x1):x2] if roi.size == 0: continue cv2.imwrite(os.path.join(save_dir, f"{img_name}_{i}.png"), roi) # 保存坐标供后续融合使用 np.save(os.path.join(save_dir, f"{img_name}_boxes.npy"), boxes)

conf=0.25是置信度阈值,骨龄检测宁可多留几个框也别漏,后面 ResNet18 可以靠分类置信度再筛。iou=0.45是 NMS 阈值,骨化中心挨得近的时候可以调到 0.5 到 0.6,避免把相邻骨骼框合并掉。导出的 ROI 文件名带原图名和序号,坐标存成 npy,后面融合骨龄时要用坐标还原位置关系。

4. ResNet18 分类发育等级:改造、训练与骨龄融合

4.1 把 ResNet18 改成骨龄等级分类器

ResNet18 原版是 1000 类 ImageNet 分类,骨龄等级通常 8 到 10 类,改最后一层全连接就行。输入通道如果是灰度图,第一层卷积也要改成单通道。下面是用 torchvision 改模型的代码。

import torch import torch.nn as nn from torchvision import models def build_resnet18(num_classes=9, in_channels=1): model = models.resnet18(pretrained=True) # 改第一层卷积为单通道 if in_channels != 3: old_conv = model.conv1 model.conv1 = nn.Conv2d(in_channels, old_conv.out_channels, kernel_size=old_conv.kernel_size, stride=old_conv.stride, padding=old_conv.padding, bias=False) # 用原三通道权重均值初始化单通道 with torch.no_grad(): model.conv1.weight[:] = old_conv.weight.mean(dim=1, keepdim=True) # 改全连接层 model.fc = nn.Linear(model.fc.in_features, num_classes) return model model = build_resnet18(num_classes=9, in_channels=1)

num_classes=9对应发育等级 0 到 8,实际按数据集等级数改。第一层卷积用原三通道权重均值初始化,比随机初始化收敛快。如果数据集够大,也可以把pretrained设成 False 从头训,但骨龄数据通常没那么多,还是用预训练权重稳。

4.2 分类训练:学习率、类别不平衡和早停

骨龄等级分布天然不平衡,低等级和高等级样本少,中间等级多。训练时用带权重的交叉熵,权重按类别频率倒数算。下面是一段训练循环。

import torch from torch.utils.data import DataLoader from torchvision import transforms, datasets from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR train_tf = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((224, 224)), transforms.RandomRotation(10), transforms.RandomAffine(degrees=0, translate=(0.05, 0.05)), transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) ]) val_tf = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) ]) train_ds = datasets.ImageFolder("cls_dataset/train", transform=train_tf) val_ds = datasets.ImageFolder("cls_dataset/val", transform=val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4) # 类别权重 class_counts = [0] * len(train_ds.classes) for _, label in train_ds.samples: class_counts[label] += 1 weights = torch.tensor([1.0 / c for c in class_counts], dtype=torch.float32) weights = weights / weights.sum() * len(class_counts) criterion = torch.nn.CrossEntropyLoss(weight=weights) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = build_resnet18(num_classes=len(train_ds.classes), in_channels=1).to(device) optimizer = AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50) best_acc = 0.0 patience = 10 no_improve = 0 for epoch in range(80): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() loss = criterion(model(imgs), labels) loss.backward() optimizer.step() scheduler.step() # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) pred = model(imgs).argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) acc = correct / total if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "resnet18_bone_best.pth") no_improve = 0 else: no_improve += 1 if no_improve >= patience: break

lr=1e-4是微调预训练模型的常用值,从头训可以到 1e-3。weight_decay=1e-4防过拟合。类别权重那段把每个类权重设成频率倒数再归一化,缓解不平衡。早停patience=10表示验证精度 10 轮不升就停。验证指标除了 accuracy,最好再看混淆矩阵,骨龄等级相邻的类容易混,比如 3 级和 4 级,如果混淆严重,说明 ROI 裁剪质量不够或者等级定义本身边界模糊。

4.3 从等级到骨龄:融合策略与坐标还原

ResNet18 输出的是每个 ROI 的等级概率,最终骨龄要把这些等级按骨骼权重和位置关系融合。常见做法有两种:一种是查表法,每个等级对应一个骨龄贡献分,所有 ROI 的贡献分求和再映射到骨龄;另一种是回归法,把 ResNet18 最后一层改成回归输出骨龄值,直接对每个 ROI 预测骨龄再平均。查表法可解释性强,适合毕设写论文;回归法端到端简单,但需要逐 ROI 的骨龄标签,很多数据集没有。我一般先用查表法。

import numpy as np import torch # 假设每个等级对应的骨龄贡献分,按 TW3 简化表 grade_to_score = {0: 0.0, 1: 0.3, 2: 0.6, 3: 0.9, 4: 1.2, 5: 1.5, 6: 1.8, 7: 2.1, 8: 2.4} def fuse_bone_age(roi_probs, boxes, grade_to_score): total_score = 0.0 count = 0 for probs, box in zip(roi_probs, boxes): grade = int(np.argmax(probs)) conf = float(np.max(probs)) if conf < 0.5: # 低置信度 ROI 不参与融合 continue total_score += grade_to_score.get(grade, 0.0) count += 1 if count == 0: return None # 简单线性映射,实际要用标准表拟合 bone_age = total_score * 1.5 + 2.0 return bone_age

conf < 0.5过滤掉分类没把握的 ROI,避免拉偏结果。grade_to_score和最后的线性映射系数要用训练集上的真实骨龄拟合,不能拍脑袋定。拟合方法可以用最小二乘,把每个样本的total_score和真实骨龄做线性回归,得到斜率和截距。如果数据集有逐骨骨龄标准,直接查表更准。

5. 避坑与排查:骨龄双模型落地最容易翻车的五件事

5.1 检测框把整个手腕框进去,分类全乱

现象:yolov5 训练 mAP 看着不低,但 ResNet18 分类精度死活上不去,混淆矩阵里所有等级混在一起。原因:标注时图省事,把整个手腕或者一大片区域框成一个目标,ROI 裁剪出来包含大量背景和无关骨骼,分类器学不到局部特征。解决:重新检查标注,骨化中心框应该紧贴骨化中心边缘,框的面积不超过骨化中心本身的两倍。用脚本统计每个框的宽高比和面积分布,异常大的框挑出来人工复核。

5.2 按图片随机划分导致指标虚高

现象:验证集准确率 95%,换一批新数据掉到 60%。原因:同一个患者的多张片子被分到训练和验证两边,模型记住了患者特征而不是骨龄特征。解决:按患者 ID 划分,确保同一个人的所有片子只出现在一边。如果数据集没给患者 ID,用图片文件名前缀或者采集时间聚类来近似划分。

5.3 灰度图送进三通道 ResNet18 导致第一层学废

现象:ResNet18 训练 loss 不降,准确率随机水平。原因:直接把单通道灰度图复制成三通道送进预训练模型,第一层卷积权重是按 RGB 自然图像学的,灰度复制后三通道完全相同,梯度更新方向不对。解决:按 4.1 节把第一层改成单通道并用原权重均值初始化,或者把灰度图用伪彩色映射成三通道再送进去,但后者多一步预处理,不如改网络直接。

5.4 骨龄融合系数拍脑袋定,结果整体偏移

现象:每个 ROI 分类都对,但最终骨龄比真实值系统性高或低。原因:grade_to_score和线性映射系数没有用真实骨龄拟合,直接抄了别的数据集或者凭感觉设。解决:在训练集上收集每个样本的total_score和真实骨龄,用numpy.polyfit做一次线性回归,把斜率和截距写回融合函数。验证集上再看 MAE,如果 MAE 大于 0.8 岁,检查是不是某些等级样本太少导致分类器偏。

5.5 推理时 ROI 坐标越界导致裁剪为空

现象:推理脚本报错或者某些图片没有输出 ROI。原因:yolov5 输出的框坐标可能超出图片边界,尤其是图片边缘的骨化中心,x2大于图片宽度或者y1小于 0。解决:裁剪前做坐标裁剪,x1 = max(0, x1),x2 = min(w, x2),y1 = max(0, y1),y2 = min(h, y2),并且判断x2 > x1和y2 > y1再裁剪。这个坑很小但很常见,血泪经验是每次换数据集都要重新检查一遍。

6. 进阶验证:用 MAE 和 Bland-Altman 图判断这套方案能不能真用

训练完模型,准确率、mAP 这些指标只能说明分类和检测本身还行,骨龄检测最终要看预测骨龄和真实骨龄的偏差。我一般会算三个指标:MAE(平均绝对误差)、RMSE(均方根误差)、以及 Bland-Altman 一致性分析。MAE 反映平均偏差,RMSE 对大误差更敏感,Bland-Altman 图能看出系统偏差和一致性界限。骨龄临床上通常要求 MAE 在 0.5 岁以内算可用,0.5 到 0.8 岁之间算参考,超过 0.8 岁基本不能直接用于诊断。

验证代码不复杂,把验证集每个样本的预测骨龄和真实骨龄收集起来:

import numpy as np import matplotlib.pyplot as plt true_ages = np.array([...]) # 真实骨龄 pred_ages = np.array([...]) # 模型预测骨龄 mae = np.mean(np.abs(pred_ages - true_ages)) rmse = np.sqrt(np.mean((pred_ages - true_ages) ** 2)) print(f"MAE: {mae:.3f}, RMSE: {rmse:.3f}") # Bland-Altman mean_vals = (true_ages + pred_ages) / 2 diff_vals = pred_ages - true_ages mean_diff = np.mean(diff_vals) std_diff = np.std(diff_vals) loa_upper = mean_diff + 1.96 * std_diff loa_lower = mean_diff - 1.96 * std_diff plt.scatter(mean_vals, diff_vals, alpha=0.5) plt.axhline(mean_diff, color='r', linestyle='--', label=f'Mean diff: {mean_diff:.2f}') plt.axhline(loa_upper, color='g', linestyle='--', label=f'+1.96SD: {loa_upper:.2f}') plt.axhline(loa_lower, color='g', linestyle='--', label=f'-1.96SD: {loa_lower:.2f}') plt.xlabel('Mean of true and predicted bone age') plt.ylabel('Predicted - True bone age') plt.legend() plt.savefig('bland_altman.png')

如果 Bland-Altman 图里散点围绕 0 线均匀分布,说明没有系统偏差;如果整体偏上或偏下,说明融合系数需要重新拟合。如果散点呈漏斗形,说明骨龄大时误差也大,可能需要分年龄段建模。另外建议按性别、年龄段分组算 MAE,骨龄检测在青春期前后误差通常更大,因为骨化中心变化快,模型在样本少的年龄段容易偏。

这套 yolov5 加 ResNet18 的方案,我自己的习惯是先把检测和分类分开调,检测 mAP 到 0.8 以上再动分类,分类混淆矩阵里相邻等级混得厉害就回去看 ROI 裁剪质量,最后融合系数一定用训练集拟合、验证集验证,不要用测试集调参。骨龄检测没有后悔药,数据标注和划分错了,后面怎么调模型都救不回来。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 2:57:26

Mac装Photoshop‘已损坏’报错终极解决方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 2:56:36

Baserow 文件上传与存储指南:从文件入库到访问控制

Baserow 文件上传与存储指南&#xff1a;从文件入库到访问控制 【免费下载链接】baserow Build databases, automations, apps & agents with AI — no code. Open source platform available on cloud and self-hosted. GDPR, HIPAA, SOC 2 compliant. Best Airtable alte…

作者头像 李华
网站建设 2026/9/26 2:54:48

浏览器里修图:WebGPU 图像修复工具 inpaint-web 上手记

浏览器里修图&#xff1a;WebGPU 图像修复工具 inpaint-web 上手记 【免费下载链接】inpaint-web A free and open-source inpainting & image-upscaling tool powered by webgpu and wasm on the browser。| 基于 Webgpu 技术和 wasm 技术的免费开源 inpainting & ima…

作者头像 李华
网站建设 2026/9/26 2:53:42

SQL Server数据库实验实战:约束、触发器与游标避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华