• 作者:老汪软件技巧
  • 发表时间:2024-09-06 21:03
  • 浏览量:

拍摄后需要给用户提供反馈是否拍摄成功,可以在组件左下角添加一个图片预览的小窗口,就像许多手机相机一样拍摄照片后左下角会有一块区域显示图片缩略图,点击后会跳转至相册。这里只实现预览功能,照片拍摄成功后左下角会出现缩略图2s后自动消失,如果短时间内连续拍照新图片会将旧缩略图覆盖。

  const timer: any = useRef(null)
  const previewImg: any = useRef(null)
    ...
    dataurl = await compressImgOnlyForTakePhots(dataurl, 0.8);
    showPreviewImg(dataurl)
    hidePreviewImg()
  }
  
    //展示预览图片
  const showPreviewImg = (dataurl) => {
    const dom: any = previewImg.current
    dom.src = dataurl
    dom.style.display = 'block'
  }
  //隐藏预览图片
  const hidePreviewImg = () => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      const dom: any = previewImg.current;
      dom.style.display = 'none';
    }, 2000);
  }
  
  previewImg} ref={previewImg} />

如何实现摄像头画面的放大和缩小

通常手机照相机通过双指滑动实现摄像头画面的放大缩小功能。我研究了一下,其实Navigator.mediaDevices.getUserMedia获取到的视频流并没有api能有焦距放大缩小的效果,看到一个摄像头插件说是可以模拟出焦距效果,但是相关的摄像头一整套组件也需要使用插件,考虑到学习成本和可能存在其他问题只能另辟蹊径。

后来我想到通过css的transform:scale放大缩小video标签的大小来达到放大缩小的效果,其实这个方法也不能算真的达到了焦距放大缩小的效果,因为这样的话放大缩小时摄像头的分辨率是不变的,所以越是放大显示的画面会越模糊,这个时候需要初始显示的画面分辨率就要清晰度高一些,我选择的就是1920 X 1440这样一个分辨率,还好最后实现的效果能够满足项目需要。

这块功能主要实现的难点在于摄像头画面方法变化后你仍然需要截取可视区上的画面,不能最后生成的图片包括到了超出页面的范围。我画了一张草图来方便理解。

接下来先把video标签放大缩小的画面效果实现出来,之后再考虑图片生成的逻辑。这里我使用按钮点击的方式来实现画面的放大缩小,我会在操作栏右侧新增一块区域专门控制画面放大缩小,放大、缩小分别一个按钮,再显示一下当前的放大倍率。

我这里将放大的倍率控制在了1~4,因为足够项目需要。

  const [scale, setscale]: any = useState(1)
    //放大
  const handleAmplify = () => {
    if (scale >= 4) {
      Taro.showToast({
        icon: 'error',
        title: '放大到最大倍数了'
      })
      return
    }
    let number = Number((scale + 0.2).toFixed(1))
    setscale(number)
    const videoElement: any = video.current
    videoElement.style.transform = `scale(${number})`;
  }
  //缩小
  const handleReduce = () => {
    //如果scale<1则无法再缩小
    if (scale <= 1) return
    const number = Number((scale - 0.2).toFixed(1))
    setscale(number)
    const videoElement: any = video.current
    videoElement.style.transform = `scale(${number})`;
  }
 <View className={styles.right}>
    <button className={styles.amplify} onClick={handleAmplify}>放大button>
    <View className={styles.scale}>当前倍率:{scale}View>
    <button className={styles.reduce} onClick={handleReduce}>缩小button>
 View>

接下来就是考虑如何将放大后的video画面在屏幕中的可视区域截取到canvas上,这里我也画一张草图来方便理解。

图片中外部的大红框就是放大一定倍数后的video标签,内部的红框就是我们真正需要截取的画面,我在图中标明了4个参数分别是XStart,yStart(中间红框的左上角位于位于外部红框的x,y轴位置),maxWidth,maxHeight(中间红框的最终分辨率),这里我们只需要这四个参数值就能得到最后的图片了。现在我们已知外部红框的分辨率为imageURLWidth、imageURLHeight,被放大的倍数为scale,那要求上面4个未知参数就简单了。

  const takePhotos = async () => {
    const videoStream = video.current.srcObject || video.current.src;
    let imageURLWidth = videoStream.getVideoTracks()[0].getSettings().width;
    let imageURLHeight = videoStream.getVideoTracks()[0].getSettings().height;
    //裁剪出的图片的x,y轴坐标
    let xStart, yStart
    xStart = imageURLWidth / 2 - 0.5 * (imageURLWidth / scale)
    yStart = imageURLHeight / 2 - 0.5 * (imageURLHeight / scale)
    // 最终要裁剪到的尺寸
    let maxWidth = imageURLWidth / scale;
    let maxHeight = imageURLHeight / scale;
    const outputCanvas = document.createElement('canvas');
    const outputContext: any = outputCanvas.getContext('2d');
    let dataurl: any
    outputCanvas.width = 480;
    outputCanvas.height = 640;
    outputContext.drawImage(video.current, xStart, yStart, maxWidth, maxHeight, 0, 0, outputCanvas.width, outputCanvas.height)
    dataurl = outputCanvas.toDataURL('image/jpeg');
    dataurl = await compressImgOnlyForTakePhots(dataurl, 0.8);
    showPreviewImg(dataurl)
    hidePreviewImg()
  }

这里了解一下canvas drawImage各个参数的含义会更好理解,要注意我这里的计算方式是针对移动端的效果,在真机上的效果能够完全符合放大缩小的需求,如果代码在PC上运行,截取照片的效果会有所差异。

如何打开手电筒

打开手电筒也非常简单只需要一个方法就能够控制,需要注意的是想要手电筒正常启动需要采用后置摄像头画面(需要摄像头参数facingMode.exact = "environment"),大部分手机的前置摄像头都没有手电筒功能。

  const [open, setopen]: any = useState(false)
  const handleFlashlight = () => {
    const videoStream = video.current.srcObject || video.current.src;
    const track = videoStream.getVideoTracks()[0]
    track.applyConstraints({
      advanced: [{ torch: !open }]
    })
    setopen(!open)
  }
  

横竖屏拍照判断

咱们手机本地照相机进行横屏拍照的时候都会进行判断如果横屏拍摄出来的照片都会进行方向矫正。当前我们组件还不支持如果横屏拍摄也没有矫正效果,最后照片拍出来就是旋转过90°的样子。现在就要来实现横屏拍摄矫正的效果。

第一个需要实现的功能就是判断当前手机是横屏状态还是竖屏状态,我在网上查了一下方案分为2种,一种是网页本身会根据手机横竖屏效果产生UI变化,就像咱们看视频一样手机一横视频就是全屏状态了,但是很可惜我项目上的网页压根没有考虑横屏效果,如果横屏状态UI效果直接惨不忍睹,所以这个方案直接pass。另一种方法就是根据手机自身的陀螺仪判断手机横竖屏状态,这个方案也有难点,首先陀螺仪并没有api可以直接告诉你手机是否处于横竖屏,陀螺仪只会以手机为中心分出x,y,z轴告诉你手机相对于3根轴线的偏移角度,还有就是不同型号手机陀螺仪输出的角度也有偏差,比如2个不同型号手机处于相同的偏移角度但是陀螺仪api输出的偏移值是不同的。已知难点后我便在github上搜索有无成熟方案可以解决这些问题,于是我找到了orientation.js,这个js包提高了陀螺仪的兼容性,将不同手机的偏移误差减小,我试验了一下确实横竖屏判断的准确性提高了。但这个包也是只抛出x,y,z轴的偏移角度,横竖屏的判断还需自己处理。不过有了方案就可以动手做起来了。

代码中我使用TransverseOrVertical来记录手机当前处于横屏还是竖屏,按着orientation.js提供的api来监听手机偏移角度的变化,并且在合适位置取消监听避免性能开销。

let TransverseOrVertical = false  //横屏或竖屏
let readings: any = [];
const sampleSize = 5; // 采样大小
const horizontalThreshold = 20; // 横屏阈值
const verticalThreshold = 45;   // 竖屏阈值
  useEffect(() => {
    var ori = new Orientation({
      initForwardSlant: 45, // 可选,初始向前倾斜度
      onChange: deviceorientationHandle
    })
    ori.init()
    return (() => {
      window.removeEventListener('deviceorientation', deviceorientationHandle)
      ori.destory()
    })
  }, [])
  
    const deviceorientationHandle = throttle((event: any) => {
    readings.push(event);
    if (readings.length >= sampleSize) {
      // 计算平均值
      const average = readings.reduce((acc, cur) => {
        return {
          alpha: acc.alpha + cur.alpha / sampleSize,
          beta: acc.beta + cur.beta / sampleSize,
          gamma: acc.gamma + cur.gamma / sampleSize
        };
      }, { alpha: 0, beta: 0, gamma: 0 });
      const beta = average.beta;     // 绕x轴旋转的平均值
      const gamma = average.gamma;   // 绕y轴旋转的平均值
      // 判断设备是否接近水平放置
      const isNearlyHorizontal = beta > -horizontalThreshold && beta < horizontalThreshold;
      // 判断设备是否接近垂直放置
      const isNearlyVertical = Math.abs(gamma) < verticalThreshold;
      if (isNearlyHorizontal && isNearlyVertical) {
        // 当设备接近水平且gamma值接近0时,根据alpha值判断横竖屏
        if (average.alpha >= -45 && average.alpha <= 45 || average.alpha >= 135 && average.alpha <= 225) {
          TransverseOrVertical = false;
        } else {
          TransverseOrVertical = true;
        }
      } else {
        // 当设备不是接近水平时,根据beta和gamma值判断横竖屏
        if (gamma >= -verticalThreshold && gamma <= verticalThreshold) {
          TransverseOrVertical = false;
        } else {
          TransverseOrVertical = true;
        }
      }
      // 清空读数数组
      readings = [];
    }
  }, 100)

这里可以在deviceorientationHandle方法最后打印出TransverseOrVertical的值,在调试器中查看横竖屏的变化。

真机测试了一下准确度还是不错的。这种判断方法还有一个缺点就是无法判断手机是顺时针90°横过来的还是逆时针90°横过来的。

横竖屏判断好后接下来就是对横屏后的图像进行处理,其实就是对横屏照片进行90°旋转即可,正常照片不做处理。

if (TransverseOrVertical) {
      outputCanvas.width = 640;
      outputCanvas.height = 480;
      outputContext.save();
      outputContext.translate(outputCanvas.width / 2, outputCanvas.height / 2);
      outputContext.rotate(-Math.PI / 2);
      outputContext.drawImage(video.current, xStart, yStart, maxWidth, maxHeight, -outputCanvas.height / 2, -outputCanvas.width / 2, outputCanvas.height, outputCanvas.width);
    } else {
      outputCanvas.width = 480;
      outputCanvas.height = 640;
      outputContext.drawImage(video.current, xStart, yStart, maxWidth, maxHeight, 0, 0, outputCanvas.width, outputCanvas.height)
    }

完整代码

import { View } from "@tarojs/components"
import styles from "./index.module.scss"
import { useEffect, useRef, useState } from "react"
import lrz from 'lrz'
import Taro from "@tarojs/taro"
import { loadScript, throttle } from "@/src/network/utils"
import Orientation from 'orientation.js'
let TransverseOrVertical = false  //横屏或竖屏
let readings: any = [];
const sampleSize = 5; // 采样大小
const horizontalThreshold = 20; // 横屏阈值
const verticalThreshold = 45;   // 竖屏阈值
const test = () => {
  const video: any = useRef(null)
  const timer: any = useRef(null)
  const previewImg: any = useRef(null)
  const [scale, setscale]: any = useState(1)
  const [open, setopen]: any = useState(false)
  useEffect(() => {
    getUserMedia(getConstrants(), getStream, noStream)
    loadScript("https://cdn.jsdelivr.net/npm/vconsole@latest/dist/vconsole.min.js")
        .then(() => {
          new window.VConsole();
        })
        .catch((error) => {
          console.log("Error:", error);
        });
        var ori = new Orientation({
          initForwardSlant: 45, // 可选,初始向前倾斜度
          onChange: deviceorientationHandle
        })
        ori.init()
        return (() => {
          window.removeEventListener('deviceorientation', deviceorientationHandle)
          ori.destory()
        })
  }, [])
  const deviceorientationHandle = throttle((event: any) => {
    readings.push(event);
    if (readings.length >= sampleSize) {
      // 计算平均值
      const average = readings.reduce((acc, cur) => {
        return {
          alpha: acc.alpha + cur.alpha / sampleSize,
          beta: acc.beta + cur.beta / sampleSize,
          gamma: acc.gamma + cur.gamma / sampleSize
        };
      }, { alpha: 0, beta: 0, gamma: 0 });
      const beta = average.beta;     // 绕x轴旋转的平均值
      const gamma = average.gamma;   // 绕y轴旋转的平均值
      // 判断设备是否接近水平放置
      const isNearlyHorizontal = beta > -horizontalThreshold && beta < horizontalThreshold;
      // 判断设备是否接近垂直放置
      const isNearlyVertical = Math.abs(gamma) < verticalThreshold;
      if (isNearlyHorizontal && isNearlyVertical) {
        // 当设备接近水平且gamma值接近0时,根据alpha值判断横竖屏
        if (average.alpha >= -45 && average.alpha <= 45 || average.alpha >= 135 && average.alpha <= 225) {
          TransverseOrVertical = false;
        } else {
          TransverseOrVertical = true;
        }
      } else {
        // 当设备不是接近水平时,根据beta和gamma值判断横竖屏
        if (gamma >= -verticalThreshold && gamma <= verticalThreshold) {
          TransverseOrVertical = false;
        } else {
          TransverseOrVertical = true;
        }
      }
      console.log(TransverseOrVertical?'横屏':'竖屏');
      // 清空读数数组
      readings = [];
    }
  }, 100)
  //将摄像机影像投到video上
  const getStream = async (stream: any) => {
    if ("srcObject" in video.current) {
      video.current.srcObject = stream
    } else {
      video.current.src = window.URL && window.URL.createObjectURL(stream) || stream
    }
    video.current.onloadedmetadata = () => {
      console.log('视频流成功加载');
      video.current.play();
    };
  }
  //获取摄像头媒体
  let getConstrants = () => {
    return {
      audio: false,
      video: {
        facingMode: { exact: "environment" },
        width: 1920,
        height: 1440,
      }
    }
  };
  const noStream = () => {
    Taro.showToast({
      title: '当前无法展示摄像头画面',
      icon: 'error'
    })
  }
  const getUserMedia = (constraints: any, success: any, error: any) => {
    const Navigator: any = navigator
    if (Navigator.mediaDevices.getUserMedia) {
      //最新的标准API
      Navigator.mediaDevices.getUserMedia(constraints).then(success).catch(error);
    } else if (Navigator.webkitGetUserMedia) {
      //webkit核心浏览器
      Navigator.webkitGetUserMedia(constraints, success, error)
    } else if (Navigator.mozGetUserMedia) {
      //firfox浏览器
      Navigator.mozGetUserMedia(constraints, success, error);
    } else if (Navigator.getUserMedia) {
      //旧版API
      Navigator.getUserMedia(constraints, success, error);
    }
  }
  const takePhotos = async () => {
    const videoStream = video.current.srcObject || video.current.src;
    let imageURLWidth = videoStream.getVideoTracks()[0].getSettings().width;
    let imageURLHeight = videoStream.getVideoTracks()[0].getSettings().height;
    //裁剪出的图片的x,y轴坐标
    let xStart, yStart
    xStart = imageURLWidth / 2 - 0.5 * (imageURLWidth / scale)
    yStart = imageURLHeight / 2 - 0.5 * (imageURLHeight / scale)
    // 最终要裁剪到的尺寸
    let maxWidth = imageURLWidth / scale;
    let maxHeight = imageURLHeight / scale;
    const outputCanvas = document.createElement('canvas');
    const outputContext: any = outputCanvas.getContext('2d');
    let dataurl: any
    if (TransverseOrVertical) {
      outputCanvas.width = 640;
      outputCanvas.height = 480;
      outputContext.save();
      outputContext.translate(outputCanvas.width / 2, outputCanvas.height / 2);
      outputContext.rotate(-Math.PI / 2);
      outputContext.drawImage(video.current, xStart, yStart, maxWidth, maxHeight, -outputCanvas.height / 2, -outputCanvas.width / 2, outputCanvas.height, outputCanvas.width);
    } else {
      outputCanvas.width = 480;
      outputCanvas.height = 640;
      outputContext.drawImage(video.current, xStart, yStart, maxWidth, maxHeight, 0, 0, outputCanvas.width, outputCanvas.height)
    }
    dataurl = outputCanvas.toDataURL('image/jpeg');
    dataurl = await compressImgOnlyForTakePhots(dataurl, 0.8);
    showPreviewImg(dataurl)
    hidePreviewImg()
  }
  //展示预览图片
  const showPreviewImg = (dataurl) => {
    const dom: any = previewImg.current
    dom.src = dataurl
    dom.style.display = 'block'
  }
  //隐藏预览图片
  const hidePreviewImg = () => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      const dom: any = previewImg.current;
      dom.style.display = 'none';
    }, 2000);
  }
  const compressImgOnlyForTakePhots = (dataURL, quality = 0.7) => {
    return new Promise((resolve) => {
      lrz(dataURL, { quality })
        .then(async function (rst) {
          // 处理成功会执行
          resolve(rst.base64)
        })
        .catch(function (err) {
          // 处理失败会执行
          console.log('图片压缩处理失败', err);
        })
    })
  }
  const handleBack = () => {
    const videoStream = video.current.srcObject || video.current.src;
    if (videoStream) {
      videoStream.getVideoTracks().forEach(track => {
        track.stop()
      })
    }
  }
  //放大
  const handleAmplify = () => {
    if (scale >= 4) {
      Taro.showToast({
        icon: 'error',
        title: '放大到最大倍数了'
      })
      return
    }
    let number = Number((scale + 0.2).toFixed(1))
    setscale(number)
    const videoElement: any = video.current
    videoElement.style.transform = `scale(${number})`;
  }
  //缩小
  const handleReduce = () => {
    //如果scale<1则无法再缩小
    if (scale <= 1) return
    const number = Number((scale - 0.2).toFixed(1))
    setscale(number)
    const videoElement: any = video.current
    videoElement.style.transform = `scale(${number})`;
  }
  const handleFlashlight = () => {
    const videoStream = video.current.srcObject || video.current.src;
    const track = videoStream.getVideoTracks()[0]
    track.applyConstraints({
      advanced: [{ torch: !open }]
    })
    setopen(!open)
  }
  return (
    <View className={styles.test}>
      <img className={styles.previewImg} ref={previewImg} />
      <View className={styles.videoHousing}>
        <video className={styles.video} ref={video}>video>
      View>
      <View className={styles.controls}>
        <View className={styles.left}>
          <button className={styles.flashlight} onClick={handleFlashlight}>{open ? '关闭' : '打开'}手电筒button>
          <button className={styles.back} onClick={handleBack}>关闭button>
        View>
        <button className={styles.save} onClick={takePhotos}>button>
        <View className={styles.right}>
          <button className={styles.amplify} onClick={handleAmplify}>放大button>
          <View className={styles.scale}>当前倍率:{scale}View>
          <button className={styles.reduce} onClick={handleReduce}>缩小button>
        View>
      View>
    View>
  )
}
export default test