Canvas艺术之旅:探索锚点抠图的无限可能

news2025/2/23 14:41:21

说在前面

在日常的图片处理中,我们经常会遇到需要抠图的情况,无论是为了美化照片、制作海报,还是进行图片合成。抠图对于我们来说也是一种很常用的功能了,今天就让我们一起来看下怎么使用canvas来实现一个锚点抠图功能。

效果展示

在这里插入图片描述

体验地址

http://jyeontu.xyz/JDemo/#/imgCut

代码实现

一、图片上传

想要进行抠图的话我们得先有图片是吧,所以要有个图片上传的功能。

1、本地图片上传

这里我们使用简单的点击按钮上传,前面也有文章介绍过了拖拽上传功能的实现,这里就不赘述了,有兴趣的可以看下这篇文章:《文件拖拽上传功能已经烂大街了,你还不会吗?》

这里我们直接使用input标签来实现上传功能即可:

<label for="file-upload" class="custom-file-upload">
    <i class="fas fa-cloud-upload-alt"></i> 选择文件
</label>
<input
    v-show="false"
    id="file-upload"
    type="file"
    accept="image/*"
    @change="handleFileUpload"
/>

image.png

handleFileUpload(e) {
  let file = e.target.files[0];
  if (!file) return;
  this.srcLink = "";
  const reader = new FileReader();
  reader.onload = event => {
    const img = new Image();
    img.onload = () => {
      this.image = img;
      this.width = img.width;
      this.height = img.height;
      this.originWidth = img.width;
      this.originHeight = img.height;
      this.drawCanvas();
    };
    img.src = event.target.result;
  };
  reader.readAsDataURL(file);
}
2、在线链接图片

使用Input输入在线图片链接:

<input
    type="input"
    @change="inputSrc"
    placeholder="输入图片在线地址"
    v-model="srcLink"
    class="input-style"
    style="width: 100%;"
/>

image.png

getImageBase64FromURL(url, callback) {
  return new Promise(resove => {
    const xhr = new XMLHttpRequest();
    xhr.onload = function() {
      const reader = new FileReader();
      reader.onloadend = function() {
        resove(reader.result);
      };
      reader.readAsDataURL(xhr.response);
    };
    xhr.open("GET", url);
    xhr.responseType = "blob";
    xhr.send();
  });
},
async inputSrc() {
  const src = await this.getImageBase64FromURL(this.srcLink);
  const img = new Image();
  img.onload = () => {
    this.image = img;
    this.width = img.width;
    this.height = img.height;
    this.drawCanvas();
  };
  img.src = src;
}
3、将上传的图片绘制到canvas中
drawCanvas() {
  setTimeout(() => {
    if (!this.image || !this.ctx) {
      return;
    }
    this.ctx.clearRect(0, 0, this.width, this.height);
    this.ctx.save();
    this.ctx.translate(this.width / 2, this.height / 2);
    this.ctx.drawImage(
      this.image,
      -this.width / 2,
      -this.height / 2,
      this.width,
      this.height
    );
    this.ctx.restore();
    this.realPoints.forEach(point => {
      this.drawPoint(point.x, point.y);
    });
    this.connectPoints(); // 每次绘制canvas后连接所有点
  }, 100);
}

使用ctx.clearRect()方法清除整个画布,以便在重新绘制之前清空之前的内容。然后,使用ctx.save()方法保存当前的绘图状态。

通过ctx.translate()方法将绘图原点移动到画布的中心位置(this.width / 2, this.height / 2),这样可以方便地绘制图像和点的坐标。

使用ctx.drawImage()方法绘制图像,参数分别为图像对象this.image、图像左上角的x和y坐标(-this.width / 2, -this.height / 2),以及图像的宽度和高度(this.width, this.height)。这样就在画布上绘制了图像。

接着使用ctx.restore()方法恢复之前保存的绘图状态。

然后,通过forEach循环遍历this.realPoints数组中的每个点,调用this.drawPoint()方法绘制每个点。

最后,调用this.connectPoints()方法连接所有的点,以绘制线条。

二、锚点选择与撤销

1、监听鼠标点击

这里我们使用canvas来展示图片:

<canvas
    ref="canvas"
    id="example-canvas"
    :width="width"
    :height="height"
    @click="canvasClick"
    tabindex="0"
></canvas>

image.png

监听canvas的点击事件并保存点击坐标

canvasClick(event) {
  if (!this.image || !this.ctx) {
    return;
  }
  const x = event.offsetX / (this.width / this.originWidth);
  const y = event.offsetY / (this.height / this.originHeight);
  this.points.push({ x, y }); // 将坐标添加到数组中
  const point = this.tranPoint({ x, y });
  this.drawPoint(point.x, point.y);
},
2、绘制锚点

前面我们获取到点击坐标了,这里我们需要在该坐标上绘制上锚点:

drawPoint(x, y) {
  // 绘制一个小圆点
  this.ctx.beginPath();
  this.ctx.arc(x, y, 4, 0, 2 * Math.PI);
  this.ctx.fillStyle = "red";
  this.ctx.fill();
  this.ctx.closePath();
  this.connectPoints(); // 每次点击后连接所有点
},

使用beginPath()方法创建路径,然后使用arc()方法绘制圆形,参数解释如下:

  • x: 圆心的x轴坐标
  • y: 圆心的y轴坐标
  • 4: 圆的半径
  • 0, 2 * Math.PI: 圆弧的起始角度和结束角度,这里表示绘制一个完整的圆

接下来设置fillStyle属性为红色,使用fill()方法填充圆形区域,并使用closePath()方法关闭路径。

3、连接锚点

用虚线将所有锚点按顺序连接起来:

connectPoints() {
  if (this.realPoints.length <= 1) {
    return;
  }
  this.ctx.beginPath();
  this.ctx.moveTo(this.realPoints[0].x, this.realPoints[0].y);
  for (let i = 1; i < this.realPoints.length; i++) {
    this.ctx.lineTo(this.realPoints[i].x, this.realPoints[i].y);
  }
  this.ctx.setLineDash([5, 5]);
  this.ctx.strokeStyle = "blue";
  this.ctx.lineWidth = 2;
  this.ctx.stroke();
  this.ctx.closePath();
}

如果realPoints数组长度大于1,接着使用beginPath()方法开始创建新的路径,并通过moveTo()方法将画笔移动到第一个点的位置(this.realPoints[0].x, this.realPoints[0].y)。随后使用for循环遍历realPoints数组中的每个点,使用lineTo()方法将画笔移动到下一个点的位置(this.realPoints[i].x, this.realPoints[i].y),从而连接所有的点。

在绘制线条之前,通过setLineDash()方法设置虚线的样式,这里是一个5像素的实线和5像素的空白,表示虚线的样式。然后设置线条的颜色为蓝色,线宽为2像素,最后通过stroke()方法绘制连接线条。最后使用closePath()方法关闭路径。

4、锚点撤销功能

平时我们都习惯了通过Ctrl+Z来撤销上一步操作,这里我们也加上,通过监听键盘按键事件来实现当用户按下Ctrl+Z组合键时,撤销最后一步锚点操作,也就是将锚点列表的最后一个删除即可:

document.addEventListener("keydown", event => {
  if (event.ctrlKey && event.key === "z") {
    event.preventDefault();
    that.undoPoint();
  }
});
undoPoint() {
  if (this.points.length > 0) {
    this.points.pop();
    this.drawCanvas();
  }
},
5、获取锚点集合

这里我们在右边预留了一个展示锚点列表的文本域

<textarea v-model="pointsStr" class="points-list"></textarea>
computed: {
    pointsStr() {
      return JSON.stringify(this.realPoints);
    }
}

image.png

image.png

大家觉得这里输出锚点集合可以做什么?这里先卖个关子,下一篇博客就会需要用到这里的锚点集合了。

三、尺寸修改

页面上我们可以对图片尺寸进行修改,便于获取不同比例下的锚点集:

1、页面图片尺寸修改
<label class="label-style"></label>
<input
    type="number"
    v-model="width"
    @input="resizeImage($event, 'width')"
    @keydown.ctrl.z.prevent
    class="input-style"
/>
<label class="label-style"></label>
<input
    type="number"
    v-model="height"
    @input="resizeImage($event, 'height')"
    @keydown.ctrl.z.prevent
    class="input-style"
/>
<label class="label-style">按比例缩放</label>
<input type="checkbox" v-model="aspectRatio" class="checkbox-style" />
resizeImageByWidth(event) {
  this.width = event.target.value ? parseInt(event.target.value) : null;
  if (this.aspectRatio && this.width) {
    this.height = Math.round(
      (this.width / this.originWidth) * this.originHeight
    );
  }
},
resizeImageByHeight(event) {
  this.height = event.target.value ? parseInt(event.target.value) : null;
  if (this.aspectRatio && this.height) {
    this.width = Math.round(
      (this.height / this.originHeight) * this.originWidth
    );
  }
},
resizeImage(event, dimension) {
  if (!this.image) {
    return;
  }
  if (dimension === "width") {
    this.resizeImageByWidth(event);
  } else if (dimension === "height") {
    this.resizeImageByHeight(event);
  }
  if (
    this.aspectRatio &&
    (!event || event.target !== document.activeElement)
  ) {
    const aspectRatio = this.originWidth / this.originHeight;
    if (this.width && !this.height) {
      this.height = Math.round(this.originWidth / aspectRatio);
    } else if (!this.width && this.height) {
      this.width = Math.round(this.originHeight * aspectRatio);
    } else if (this.width / aspectRatio < this.height) {
      this.width = Math.round(this.originHeight * aspectRatio);
    } else {
      this.height = Math.round(this.originWidth / aspectRatio);
    }
  }
  this.$refs.canvas.width = this.width ? this.width : null;
  this.$refs.canvas.height = this.height ? this.height : null;
  this.image.width = this.width;
  this.image.height = this.height;
  this.drawCanvas();
}

根据 dimension 的值(可能是 “width” 或 “height”),调用相应的方法来调整图像的宽度或高度。

resizeImageByWidth(event) 方法用于根据给定的宽度调整图像的大小。它首先将 event.target.value 转换为整数,并将结果赋值给 this.width。然后,如果启用了纵横比 (this.aspectRatio) 并且 this.width 有值,则计算出相应的高度,使得调整后的图像与原始图像保持相同的纵横比。

resizeImageByHeight(event) 方法用于根据给定的高度调整图像的大小。它的逻辑与 resizeImageByWidth(event) 类似,只是操作的是 this.height 和宽高比的计算方式不同。

接下来,如果启用了纵横比 (this.aspectRatio) 并且没有通过键盘事件触发该方法,则根据原始图像的宽高比 (this.originWidth / this.originHeight) 进行额外的调整。具体的调整逻辑如下:

  • 如果只设置了宽度 (this.width) 而没有设置高度 (this.height),则根据原始图像的宽高比计算出相应的高度。
  • 如果只设置了高度 (this.height) 而没有设置宽度 (this.width),则根据原始图像的宽高比计算出相应的宽度。
  • 如果设置了宽度和高度,并且根据当前的宽高比计算出的宽度小于当前的高度,则根据原始图像的宽高比计算出相应的宽度。
  • 否则,根据原始图像的宽高比计算出相应的高度。

最后,根据调整后的宽度和高度,更新画布(this.$refs.canvas.widththis.$refs.canvas.height),以及图像的宽度和高度 (this.image.widththis.image.height)。然后调用 drawCanvas() 方法重新绘制画布。

2、锚点根据缩放比例进行修改

图片缩放之后,锚点位置也要进行对应的缩放。

tranPoint(point) {
  let { x, y } = point;
  x = x * (this.width / this.originWidth);
  y = y * (this.height / this.originHeight);
  return { x, y };
}

四、抠图预览

1、图片预览组件

这里我们简单编写一个图片预览弹窗组件:

<template>
  <div>
    <div class="preview-overlay" @click="hidePreview">
      <img :src="currentImage" alt="preview image" class="preview-image" />
      <div class="export-button" @click.stop="handleExport">
        <span>导出图片</span>
        <span class="shine"></span>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: "previewImg",
  props: {
    imageList: {
      type: Array,
      default: () => []
    },
    currentImage: {
      type: String,
      default: ""
    }
  },
  data() {
    return {};
  },
  methods: {
    hidePreview() {
      this.$emit("close");
    },
    handleExport() {
      this.$emit("export", this.currentImage);
    }
  }
};
</script>
<style>
.preview-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.8);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 999;
}
.preview-image {
  max-width: 80%;
  max-height: 80%;
  object-fit: contain;
}
.export-button {
  position: absolute;
  bottom: 20px;
  padding: 10px;
  background-color: #00aaff;
  color: white;
  border-radius: 5px;
  cursor: pointer;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 16px;
  font-weight: bold;
  text-align: center;
  box-shadow: 0 0 10px #00aaff;
  overflow: hidden;
}
.export-button:hover {
  background-color: #00e5ff;
}
.shine {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-image: linear-gradient(
    45deg,
    #ffffff 10%,
    rgba(255, 255, 255, 0) 50%,
    rgba(255, 255, 255, 0) 100%
  );
  animation: exportButtonShine 2s linear infinite;
}
@keyframes exportButtonShine {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
</style>

模板部分包含了一个遮罩层和图片预览,以及一个导出按钮。当用户点击遮罩层时,会触发 hidePreview 方法,关闭预览。图片预览部分使用了动态绑定的 :src 属性来显示当前的图片,而导出按钮则绑定了 handleExport 方法,在点击时会触发导出操作。

脚本部分定义了名为 “previewImg” 的组件,其中包括了两个属性 imageListcurrentImage,分别用于接收图片列表和当前显示的图片。在方法部分,定义了 hidePreview 方法用于关闭预览,并通过 $emit 向父组件发送 “close” 事件,以通知父组件关闭预览。另外还有 handleExport 方法,用于处理导出操作,并通过 $emit 向父组件发送 “export” 事件,并传递当前图片的路径。

2、抠图操作
cutImg() {
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  if (!this.image || !ctx) {
    return;
  }
  const image = this.image;
  canvas.width = image.width;
  canvas.height = image.height;

  // 定义剪切路径
  const cutPath = this.realPoints;
  ctx.beginPath();
  ctx.moveTo(cutPath[0].x, cutPath[0].y);
  for (let i = 1; i < cutPath.length; i++) {
    ctx.lineTo(cutPath[i].x, cutPath[i].y);
  }
  ctx.closePath();
  ctx.clip();

  // 绘制图片
  ctx.drawImage(image, 0, 0, this.width, this.height);
  // 将Canvas元素转换为PNG图像
  const imgData = canvas.toDataURL("image/png");
  this.currentImage = imgData;
  this.showImg = true;
}

获取要剪切的图片对象,并根据该图片的宽度和高度设置 <canvas> 的宽度和高度。

然后,定义剪切路径,通过遍历 cutPath 数组中的点坐标,使用 ctx.lineTo() 方法绘制路径。最后使用 ctx.closePath() 方法闭合路径,并调用 ctx.clip() 方法将剪切路径应用于上下文。

接着,使用 ctx.drawImage() 方法绘制剪切后的图片。传入的参数包括原始图片对象、剪切后的起始点坐标以及剪切后的宽度和高度。

最后,使用 canvas.toDataURL() 方法将 <canvas> 元素转换为 base64 编码的 PNG 图像数据,并将该数据赋值给 imgData 变量。然后将 imgData 赋值给 currentImage 属性,将剪切后的图片显示出来(通过在模板中绑定 currentImage)。

五、导出抠图图片

downloadImg(imgData) {
  // 创建一个链接元素,将图像数据作为URL设置给它
  const link = document.createElement("a");
  link.download = "myImage.png";
  link.href = imgData;

  // 触发链接的下载事件
  link.click();
}

首先,通过 document.createElement("a") 创建一个 <a> 元素,并将该元素赋值给 link 变量。

然后,将要下载的图片的文件名设置为 “myImage.png”,可以根据实际需要修改。

接下来,将图片数据 imgData 设置为链接元素的 href 属性,这样点击链接时会下载该图片。

最后,通过调用 link.click() 方法触发链接的点击事件,从而触发下载操作。

image.png

image.png

image.png

源码地址

gitee

https://gitee.com/zheng_yongtao/jyeontu-vue-demo.git

公众号

关注公众号『前端也能这么有趣』发送 vueDemo即可获取源码。

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1253322.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

从0到0.01入门 Webpack| 005.精选 Webpack面试题

&#x1f90d; 前端开发工程师&#xff08;主业&#xff09;、技术博主&#xff08;副业&#xff09;、已过CET6 &#x1f368; 阿珊和她的猫_CSDN个人主页 &#x1f560; 牛客高级专题作者、在牛客打造高质量专栏《前端面试必备》 &#x1f35a; 蓝桥云课签约作者、已在蓝桥云…

【c++】——类和对象(下) ——内存管理

作者:chlorine 专栏:c专栏 目录 &#x1f4bb; C/C内存分布 &#x1f4bb;C语言中动态内存管理方式&#xff1a;malloc/calloc/realloc/free ​编辑 &#x1f4bb;C内存管理方式 &#x1f449;new/delete操作内置类型 &#x1f449;new和delete操作自定义类型 &#x1f…

巧妙解决接口测试产生脏数据问题

测试数据创建后需要对其删除&#xff0c;不然可能产生脏数据&#xff0c;对开发和测试、生产环境造成一定影响。 其接口框架是基于Python&#xff0c;API规范基于REST。 产生原因 改进前&#xff1a;清除资源的操作放在每个正向测试用例里&#xff0c;没有在setUp和tearDown…

战地5无限序章(无法保存)的解决办法

启动游戏后&#xff0c;目录就会自动变成这样了&#xff0c;也不会无限循环了&#xff01;

QT自定义控件: QLineEdit设置密码可见性选择的三种方式(小眼睛样式)

一、重写QLineEdit提升控件(最好用) 代码中创建了PasswordLineEdit的自定义类,该类继承自QLineEdit。这个自定义类添加了一个QPushButton,用于切换密码的可见性。 实现步骤: 在构造函数中,创建了一个 QPushButton 对象 toggleButton 作为切换密码可见性的按钮,并设置了…

柑橘病害数据集(四类图像分类,没有打yolo标签)

1.文件夹分为训练集和测试集 在这个数据集中&#xff0c;有一类是新鲜柑橘&#xff0c;还有另外三种疾病&#xff0c;溃疡病、黑斑病和绿化病。 2.train文件夹 2.1.blackspot&#xff08;黑斑病&#xff09; 文件夹 206张照片 2.2.canker&#xff08;溃疡病&#xff09; 文…

cesium轨迹线(图片轨迹线)

cesium轨迹线(图片轨迹线) 下面有源码 实现思路 使用ellipse方法加载圆型,修改polyline中‘material’方法重写glsl来实现当前效果(cesium版本1.109) 示例代码 index.html <!DOCTYPE html> <html lang="en"><head

详解重排重绘

详解重排重绘 前言页面渲染页面渲染 重排重绘重排重绘哪个更耗时间避免重排重绘发生重排重绘实例 前言 本文主要讲解在页面加载成功后可能因为页面中DOM元素样式或布局被修改从而引发的重排重绘进行一个讲解&#xff0c;那么好&#xff0c;本文正式开始. 页面渲染 因为重排重…

类与对象——(1)初识对象——C++中的string

归纳编程学习的感悟&#xff0c; 记录奋斗路上的点滴&#xff0c; 希望能帮到一样刻苦的你&#xff01; 如有不足欢迎指正&#xff01; 共同学习交流&#xff01; &#x1f30e;欢迎各位→点赞 &#x1f44d; 收藏⭐ 留言​&#x1f4dd; 或许不安或许迷惑&#xff0c;但…

【 一篇通】H5 Canvas

文章目录 Canvas的创建(HTMLCanvasElement)图形绘制&#xff1a;H5为Canvas对应的2D上下文Context提供了一系列的画图接口保存save、恢复restore、变换Transformations Canvas的创建(HTMLCanvasElement) 定义canvas HTML元素&#xff0c;默认长宽300x150 <canvas width&qu…

BER编码规则

文章目录 一、BER 编码规则介绍二、BER编码数据组成三、Identifier octets Type1. tag class 分类2. p/c 分类3 tag type4. ASN.1的原始数据类型&#xff08;TAG&#xff09; 四、 Length octets1、定长的短格式2、不定长格式3、保留格式 五、Contents octets六 、BER编码番外 …

20231125硬盘电源线5线不能识别日立10T的硬盘的解决方法

20231125硬盘电源线5线不能识别日立10T的硬盘的解决方法 2023/11/25 23:00 缘起&#xff0c;在拼多多买了2片10TB的7200rpm的日立二手硬盘。 型号&#xff1a;日立 mar-2018 10T硬盘 接上电脑&#xff0c;硬盘感觉在转动了【正常上电了。】 但是X99主板&#xff0c;在WIN10下就…

leetcode设计循环队列(链表方式来实现)

上次我们那个设计循环队列的时候用的是数组&#xff0c;因为那个时候还是不太会链表&#xff0c;现在有了链表的思路&#xff0c;我们一起来看看解题步骤吧。 https://leetcode.cn/problems/design-circular-queue/description/ 设计循环队列 那我们其实最主要的就是我们这个…

时间序列预测实战(十九)魔改Informer模型进行滚动长期预测(科研版本)

论文地址->Informer论文地址PDF点击即可阅读 代码地址-> 论文官方代码地址点击即可跳转下载GIthub链接 个人魔改版本地址-> 文章末尾 一、本文介绍 在之前的文章中我们已经讲过Informer模型了&#xff0c;但是呢官方的预测功能开发的很简陋只能设定固定长度去预测未…

微软 Edge 浏览器目前无法支持 avif 格式

avif 格式在微软 Edge 浏览器中还是没有办法支持。 如果你希望能够查看 avif 格式&#xff0c;那么只能通过浏览器打开&#xff0c;然后浏览器将会把这个文件格式下载到本地。 avif 格式已经在其他的浏览器上得到了广泛的支持&#xff0c;目前不支持的可能就只有 Edge 浏览器。…

drool 7 multiThread 测试

基本信息 通过option &#xff0c;使用如下代码进行设置 //线程数量10MaxThreadsOption optionMaxThreadsOption.get(10);kieBaseConf.setOption(option);kieBaseConf.setOption(MultithreadEvaluationOption.YES);并发是以CompositeDefaultAgenda/Rule为颗粒度来的&#xff0…

Linux篇:文件管理

一、共识原理&#xff1a; 1. 文件内容属性&#xff0c;内容与属性都是数据&#xff0c;都要在磁盘中保存。 2. 文件分为打开的文件和没打开的文件。 3. 研究打开的文件&#xff1a;本质是研究进程和文件的关系&#xff0c;因为是进程负责打开文件。 4. 没打开的文件在存储介质…

【docker】docker的基础命令

基础操作 docker info #查看docker的基本信息docker version #查看docker版本信息一、镜像操作 1、搜索镜像 docker search nginx2、下载镜像 docker pull nginx#从仓库中下载镜像&#xff0c;若没有指定标签&#xff0c;则下载最新的版本&#xff0c;也就是标签为: lat…

使用 STM32 读取和解析 NTC 热敏电阻的数值

本文介绍了如何利用 STM32 微控制器读取和解析 NTC&#xff08;Negative Temperature Coefficient&#xff09;热敏电阻的数值。首先&#xff0c;我们将简要介绍 NTC 热敏电阻的原理和特性。接下来&#xff0c;我们将详细讨论如何设计电路连接和采用合适的 STM32 外设进行数值读…

【超强笔记软件】Obsidian实现免费无限流量无套路云同步

【超强笔记软件】Obsidian如何实现免费无限流量无套路云同步&#xff1f; 目录 一、简介 软件特色演示&#xff1a; 二、使用免费群晖虚拟机搭建群晖Synology Drive服务&#xff0c;实现局域网同步 1 安装并设置Synology Drive套件 2 局域网内同步文件测试 三、内网穿透群…