Răsfoiți Sursa

refactor: refactor image worker

chenhao 2 ani în urmă
părinte
comite
babd58ba50
32 a modificat fișierele cu 469 adăugiri și 476 ștergeri
  1. 1 0
      package.json
  2. 6 0
      pnpm-lock.yaml
  3. 2 1
      server.config.ts
  4. 0 1
      src/bootstrap.ts
  5. 6 2
      src/components/shared/MarkHeader.vue
  6. 1 1
      src/components/shared/ScoringPanel.vue
  7. 2 2
      src/hooks/useMarkHeader.ts
  8. 68 412
      src/hooks/useSetImgBg.ts
  9. 0 1
      src/modules/analysis/view-marked-detail/index.vue
  10. 3 0
      src/modules/bootstrap/login/index.vue
  11. 0 1
      src/modules/example/ImageModify.vue
  12. 0 1
      src/modules/expert/assess/index.vue
  13. 0 1
      src/modules/expert/expert/index.vue
  14. 0 1
      src/modules/expert/sample/index.vue
  15. 0 1
      src/modules/expert/standard/index.vue
  16. 0 1
      src/modules/expert/training/index.vue
  17. 0 1
      src/modules/marking/arbitration/index.vue
  18. 0 1
      src/modules/marking/inquiry-result/index.vue
  19. 0 1
      src/modules/marking/mark/index.vue
  20. 0 1
      src/modules/marking/problem/index.vue
  21. 0 1
      src/modules/marking/repeat/index.vue
  22. 0 1
      src/modules/marking/similar/index.vue
  23. 0 1
      src/modules/marking/training-record/index.vue
  24. 0 1
      src/modules/marking/view-sample/index.vue
  25. 0 1
      src/modules/monitor/system-check/index.vue
  26. 0 1
      src/modules/monitor/training-monitoring-detail/index.vue
  27. 18 1
      src/modules/monitor/training-monitoring/hooks/useFormFilter.ts
  28. 23 8
      src/modules/monitor/training-monitoring/index.vue
  29. 0 1
      src/modules/quality/self-check-detail/index.vue
  30. 0 1
      src/modules/quality/subjective-check/index.vue
  31. 338 29
      src/utils/image.worker.ts
  32. 1 0
      tsconfig.json

+ 1 - 0
package.json

@@ -37,6 +37,7 @@
     "@types/big.js": "^6.1.6",
     "@types/crypto-js": "^4.1.1",
     "@types/lodash-es": "^4.17.6",
+    "@types/offscreencanvas": "^2019.7.0",
     "@typescript-eslint/eslint-plugin": "^5.38.0",
     "@typescript-eslint/parser": "^5.38.0",
     "@vitejs/plugin-vue": "^3.1.0",

+ 6 - 0
pnpm-lock.yaml

@@ -5,6 +5,7 @@ specifiers:
   '@types/big.js': ^6.1.6
   '@types/crypto-js': ^4.1.1
   '@types/lodash-es': ^4.17.6
+  '@types/offscreencanvas': ^2019.7.0
   '@typescript-eslint/eslint-plugin': ^5.38.0
   '@typescript-eslint/parser': ^5.38.0
   '@vitejs/plugin-vue': ^3.1.0
@@ -66,6 +67,7 @@ devDependencies:
   '@types/big.js': 6.1.6
   '@types/crypto-js': 4.1.1
   '@types/lodash-es': 4.17.6
+  '@types/offscreencanvas': 2019.7.0
   '@typescript-eslint/eslint-plugin': 5.40.0_25sstg4uu2sk4pm7xcyzuov7xq
   '@typescript-eslint/parser': 5.40.0_z4bbprzjrhnsfa24uvmcbu7f5q
   '@vitejs/plugin-vue': 3.1.2_vite@3.1.7+vue@3.2.40
@@ -703,6 +705,10 @@ packages:
     resolution: {integrity: sha512-WdlVphvfR/GJCLEMbNA8lJ0lhFNBj4SW3O+O5/cEGw9oYrv0al9zTwuQsq+myDUXgNx2jgBynoVgZ2MMJ6pbow==}
     dev: true
 
+  /@types/offscreencanvas/2019.7.0:
+    resolution: {integrity: sha512-PGcyveRIpL1XIqK8eBsmRBt76eFgtzuPiSTyKHZxnGemp2yzGzWpjYKAfK3wIMiU7eH+851yEpiuP8JZerTmWg==}
+    dev: true
+
   /@types/plist/3.0.2:
     resolution: {integrity: sha512-ULqvZNGMv0zRFvqn8/4LSPtnmN4MfhlPNtJCTpKuIIxGVGZ2rYWzFXrvEBoh9CVyqSE7D6YFRJ1hydLHI6kbWw==}
     requiresBuild: true

+ 2 - 1
server.config.ts

@@ -3,8 +3,9 @@ import type { ServerOptions } from 'vite'
 const server: ServerOptions = {
   proxy: {
     '^/?(api|file)/': {
-      target: 'http://192.168.10.41:8200',
+      // target: 'http://192.168.10.41:8200',
       // target: 'http://cet-test.markingtool.cn',
+      target: 'http://cet-dev.markingtool.cn:8200',
     },
   },
 }

+ 0 - 1
src/bootstrap.ts

@@ -1,7 +1,6 @@
 import { clearTasks } from '@/utils/shared'
 
 export default () => {
-  console.log('bootstrap!')
   /** 不一定正常退出, 补偿处理 */
   window.addEventListener('beforeunload', () => {
     clearTasks()

+ 6 - 2
src/components/shared/MarkHeader.vue

@@ -28,6 +28,7 @@
 <script setup lang="ts" name="MarkHeader">
 import { reactive, ref, computed, useAttrs, watch, onUnmounted } from 'vue'
 import { useRoute } from 'vue-router'
+import { add, minus } from '@/utils/common'
 import useMainStore from '@/store/main'
 import SvgIcon from '@/components/common/SvgIcon.vue'
 import ColorPicker from '@/components/common/ColorPicker.vue'
@@ -162,14 +163,17 @@ const onOperationClick = (button: HeaderButton) => {
   }
   switch (button.type) {
     case 'scale-up':
-      ratio.value = validVal(ratio.value + 0.1, 2, 0.5)
+      ratio.value = validVal(add(ratio.value, 0.1), 2, 0.5)
       break
     case 'scale-down':
-      ratio.value = validVal(ratio.value - 0.1, 2, 0.5)
+      ratio.value = validVal(minus(ratio.value, 0.1), 2, 0.5)
       break
     case 'rotate':
       rotate.value = (rotate.value + 90) % 360
       break
+    case 'center':
+      center.value = !center.value
+      break
     default:
       emitEvent(button.type)
       break

+ 1 - 1
src/components/shared/ScoringPanel.vue

@@ -51,7 +51,7 @@ const props = withDefaults(
     score: (number | string)[]
     mainNumber?: number | null
     id?: number | null
-    autoVisible: boolean | undefined
+    autoVisible?: boolean | undefined
   }>(),
   { modal: false, toggleModal: true, score: () => [], mainNumber: null, id: null, autoVisible: true }
 )

+ 2 - 2
src/hooks/useMarkHeader.ts

@@ -29,8 +29,8 @@ const useMarkHeader = () => {
   }
 
   /** 居中 */
-  const onCenter = () => {
-    imgOperation.center = !imgOperation.center
+  const onCenter = (center: boolean) => {
+    imgOperation.center = center
   }
 
   /** 旋转 */

+ 68 - 412
src/hooks/useSetImgBg.ts

@@ -1,209 +1,31 @@
-import { ref, shallowRef, watch, computed, nextTick } from 'vue'
+import { ref, onScopeDispose, watch, nextTick, unref } from 'vue'
 import type { Ref } from 'vue'
-import { reject } from 'lodash-es'
-import { createFakeDom, isDom } from '@/utils/common'
+import { isDom } from '@/utils/common'
 import TintImageWorker from '@/utils/image.worker?worker'
 
-export type Point = [number, number]
-
 export type RGBA = number[]
 
-export interface GetPixPointParams {
-  width: number
-  height: number
-}
-
-/**
- * @description 相似度阀值
- */
-export const DISTANCE = 45
-
-/**
- * @description 黑色RGBA
- */
-export const BLACK = [0, 0, 0, 255]
-
-/**
- * @description 白色RGBA
- */
-export const WHITE = [255, 255, 255, 255]
-
-/**
- * @description 背景色取色点
- */
-export const COLOR_POINT: Point[] = [
-  [0, 0],
-  [0.25, 0],
-  [0.5, 0],
-  [0.75, 0],
-  [1, 0],
-  [0, 0.25],
-  [1, 0.25],
-  [0, 0.5],
-  [1, 0.5],
-  [0, 0.75],
-  [1, 0.75],
-  [0, 1],
-  [0.25, 1],
-  [0.5, 1],
-  [0.75, 1],
-  [1, 1],
-]
-
-/**
- * @description 根据取色点获取图片像素点
- * @params { Object } width canvas宽度
- * @params { Object } height canvas高度
- */
-
-export const getPixelPoint = ({ width, height }: GetPixPointParams, colorPoint: Point[] = COLOR_POINT) => {
-  return colorPoint.map(([x, y]) => {
-    const index = Math.max(Math.ceil(width * x), 0) + Math.max(Math.ceil(height * y) - 1, 0) * width
-    return Math.max(index + Math.ceil(x) * -1, 0) * 4
-  })
-}
-
-/**
- * @description 计算像素点相似度
- */
-export const calcPixSim = (rgba: RGBA, base: RGBA = WHITE) => {
-  const [r, g, b, a] = rgba
-  const [r1, g1, b1, a1] = base
-  return Math.sqrt((r - r1) ** 2 + (g - g1) ** 2 + (b - b1) ** 2 + (a - a1) ** 2)
-}
-
-/**
- * @description 判断两个颜色是否相似
- */
-export const isSimilar = (color1: RGBA, color2: RGBA, distance: number = DISTANCE) => {
-  return calcPixSim(color1, color2) <= distance
-}
-
-/**
- * @description 根据imageData起始下标获取rgba
- */
-export const getColorFromIndex = (index: number, data: Uint8ClampedArray) => {
-  return [0, 1, 2, 3].map((_) => {
-    return data[index + _]
-  })
-}
-
-/**
- * @description 根据imageData起始下标设置rgba
- */
-export const setColorFromIndex = (index: number, data: Uint8ClampedArray, color: RGBA) => {
-  return [0, 1, 2, 3].forEach((_) => {
-    data[index + _] = color[_]
-  })
-}
-
-/**
- * @description 获取各取色点中出现次数最多的颜色
- */
-export const getMainColor = (colors: RGBA[]) => {
-  let max = 1
-  let index = 0
-  let num = 0
-  const total = colors
-    .map((_) => calcPixSim(_))
-    .reduce((r, n, i) => {
-      r[n] ??= 0
-      r[n]++
-      if (r[n] > max) {
-        max = r[n]
-        num = n
-        index = i
-      }
-      return r
-    }, {} as Record<number, number>)
-  return {
-    max,
-    index,
-    num,
-    total,
-    mainColor: colors[index],
-  }
-}
-
-/**
- * @description 从字符串中提取颜色值
- * @example "rgba(121,121,121,1)"
- */
-
-export const convertColor = (color: string) => {
-  const [r, g, b, a] = color
-    .replace(/rgba\((.*)\)/g, '$1')
-    .split(',')
-    .map((s) => s.trim()) as unknown as RGBA
-  return [+r, +g, +b, a * 255]
-}
-
+export type Point = [number, number]
 export interface SetImgBgOption {
-  image?: HTMLImageElement | string | null
-  canvas?: HTMLCanvasElement | null
+  image?: string | HTMLImageElement | null
   basePoint?: Point[]
-  useNaturalSize?: boolean
   distance?: number
-  markColor?: RGBA
-  immediate?: boolean
   enableSharpen?: boolean
   rotate?: number
   scale?: number
 }
 
-function canvasRotate(inputCanvas: HTMLCanvasElement, rotate = 0) {
-  let W = inputCanvas.width
-  let H = inputCanvas.height
-  const canvasWidth = Math.max(W, H)
-  const canvas = createFakeDom('canvas')
-  canvas.width = canvas.height = canvasWidth
-  const ctx = canvas.getContext('2d')
-  if (!ctx) {
-    return inputCanvas
-  }
-
-  ctx.translate(canvasWidth / 2, canvasWidth / 2)
-  ctx.rotate(rotate * (Math.PI / 180))
-
-  let x = -canvasWidth / 2
-  let y = -canvasWidth / 2
-
-  rotate = rotate % 360
-
-  console.log(rotate)
-
-  if (rotate % 180 !== 0) {
-    if (rotate === -90 || rotate === 270) {
-      x = -W + canvasWidth / 2
-    } else {
-      y = canvasWidth / 2 - H
-    }
-    const c = W
-    W = H
-    H = c
-  } else if (rotate) {
-    x = canvasWidth / 2 - W
-    y = canvasWidth / 2 - H
-  }
-  ctx.drawImage(inputCanvas, x, y)
-  const canvas2 = createFakeDom('canvas')
-  canvas2.width = W
-  canvas2.height = H
-  const ctx2 = canvas2.getContext('2d')
-  if (!ctx2) {
-    return canvas
-  }
-  ctx2.drawImage(canvas, 0, 0, W, H, 0, 0, W, H)
-  return canvas2
+interface InitOption {
+  image: string | HTMLImageElement
 }
 
-const getImage = async (image?: string | HTMLImageElement | null) => {
+export const initImage = async ({ image }: InitOption) => {
   if (!image) {
     return console.warn(`return for img define ${image}`)
   }
 
   if (!isDom<HTMLImageElement>(image)) {
-    image = await new Promise<HTMLImageElement>((resolve) => {
+    image = await new Promise<HTMLImageElement>((resolve, reject) => {
       const img = new Image()
       img.src = image as string
       img.onload = () => {
@@ -215,258 +37,92 @@ const getImage = async (image?: string | HTMLImageElement | null) => {
   return image
 }
 
-interface GetImageDataOptions {
-  image: HTMLImageElement
-  canvas?: HTMLCanvasElement | null
-  useNaturalSize?: boolean
-  scale?: number
-}
-
-interface GetImageDataResult {
-  imageData: ImageData | null
-  canvas: HTMLCanvasElement | null
-  context: CanvasRenderingContext2D | null
-}
-
-const getImageData = async ({
-  image,
-  canvas,
-  useNaturalSize,
-  scale = 1,
-}: GetImageDataOptions): Promise<GetImageDataResult> => {
-  const result: GetImageDataResult = {
-    imageData: null,
-    canvas: null,
-    context: null,
-  }
-
-  const cvs = canvas || createFakeDom('canvas')
-  const ctx = cvs.getContext('2d')
-
-  if (!ctx) {
-    console.warn(`return for canvas context define ${ctx}`)
-    return result
-  }
-
-  const width: number = (cvs.width = (useNaturalSize ? image.naturalWidth : image.width) * scale)
-  const height: number = (cvs.height = (useNaturalSize ? image.naturalHeight : image.height) * scale)
-
-  if (width <= 0 || height <= 0) {
-    console.warn(`return for img size define width: ${width}, height: ${height}`)
-    return result
-  }
-
-  ctx.drawImage(image, 0, 0, width, height)
-
-  const imageData = ctx.getImageData(0, 0, width, height)
-
-  return Object.assign(result, { imageData, canvas: cvs, context: ctx })
-}
-
-interface TintImageMarkOption {
-  imageData: ImageData
-  markColor: RGBA
-  markPointIndex: number[]
-}
-
-const start = (arr: Uint8ClampedArray, func: (index: number) => void) => {
-  let i = 0
-  const run = () => {
-    if (i < arr.length) {
-      func(i++)
-      requestAnimationFrame(run)
-    }
-  }
-  requestAnimationFrame(run)
-}
-
-const tingImageMark = ({ imageData, markColor, markPointIndex }: TintImageMarkOption) => {
-  start(imageData.data, (index) => {
-    if (markColor && markPointIndex.includes(index)) {
-      setColorFromIndex(index, imageData.data, markColor)
-    }
-  })
-}
-
-interface SharpenImageOption {
-  imageData: ImageData
-  distance: number
-}
-
-const sharpenImage = ({ imageData, distance }: SharpenImageOption) => {
-  start(imageData.data, (index) => {
-    if (isSimilar(getColorFromIndex(index, imageData.data), BLACK, distance)) {
-      setColorFromIndex(index, imageData.data, BLACK)
-    }
-  })
-}
-
-interface GetMainColorOption {
-  canvas: HTMLCanvasElement
-  imageData: ImageData
-  basePoint?: Point[]
-}
-
-const getMainColorWithPointes = ({ canvas, imageData, basePoint }: GetMainColorOption) => {
-  const pixelPoints = getPixelPoint({ width: canvas.width, height: canvas.height }, basePoint)
-
-  const pixelsInfo = pixelPoints.map((_) => getColorFromIndex(_, imageData.data))
-  const { mainColor } = getMainColor(pixelsInfo)
-  return {
-    pixelPoints,
-    pixelsInfo,
-    mainColor,
-  }
-}
-
-interface TintImageOption {
-  imageData: ImageData
-  mainColor: RGBA
-  distance: number
-}
-
-const tintImageWorker = new TintImageWorker()
-
-const tintImage = async ({ mainColor, imageData, distance }: TintImageOption) => {
-  // tintImageWorker.postMessage({ distance, mainColor: [...mainColor], imageData: { ...imageData } })
-
-  // return new Promise<ImageData>((resolve) => {
-  //   tintImageWorker.addEventListener('message', (e) => {
-  //     resolve(e.data)
-  //   })
-  // })
-  const targetColor = [255, 255, 255, 0]
-  if (isSimilar(targetColor, mainColor, distance)) {
-    return imageData
-  }
-  start(imageData.data, (index) => {
-    if (isSimilar(getColorFromIndex(index, imageData.data), mainColor, distance)) {
-      setColorFromIndex(index, imageData.data, targetColor)
-    }
-  })
-  return imageData
+interface MessageData {
+  drawing: boolean
+  blob?: Blob
 }
 
 export const useSetImgBg = (option: Ref<SetImgBgOption>) => {
+  const imageWorker = new TintImageWorker()
   const drawing = ref<boolean>(false)
-  const imageData = shallowRef<ImageData | null>(null)
-  const rawCanvas = shallowRef<HTMLCanvasElement | null>(null)
-  const rawContext = shallowRef<CanvasRenderingContext2D | null>(null)
-  const rawPixelPoints = ref<number[]>([])
-  const rawMainColor = ref<RGBA>()
-  const imageElement = ref<HTMLImageElement>()
   const dataUrl = ref('')
 
-  let task: number | null = null
-
-  const updateImageData = async (image?: HTMLImageElement, scale?: number) => {
-    try {
-      dataUrl.value = ''
-      imageData.value = null
-      rawCanvas.value = null
-      rawContext.value = null
-      if (image) {
-        const {
-          imageData: outPutImageData,
-          canvas,
-          context,
-        } = await getImageData({
-          image,
-          canvas: option.value.canvas,
-          scale,
-          useNaturalSize: option.value.useNaturalSize,
-        })
-        updateCanvas(option.value.rotate, true)
-        imageData.value = outPutImageData
-        rawCanvas.value = canvas
-        rawContext.value = context
-      }
-    } catch (error) {
-      console.error(error)
-    }
-  }
+  imageWorker.addEventListener('message', (e) => {
+    const data = e.data as MessageData
+    drawing.value = data.drawing
+    dataUrl.value = data.blob ? URL.createObjectURL(data.blob) : ''
+  })
 
   watch(
-    () => option.value.image,
+    [
+      () => option.value?.image,
+      () => option.value?.distance,
+      () => option.value?.basePoint,
+      () => option.value?.enableSharpen,
+    ],
     () => {
-      getImage(option.value.image).then((res) => {
-        imageElement.value = res || undefined
+      nextTick(() => {
+        const opt = unref(option)
+        if (!opt.image) {
+          drawing.value = false
+          dataUrl.value = ''
+          return
+        }
+        initImage({ image: opt.image }).then((image) => {
+          if (!image) return
+          createImageBitmap(image).then((imageBitMap) => {
+            imageWorker.postMessage(
+              {
+                type: 'init',
+                imageBitMap,
+                basePoint: opt.basePoint,
+                enableSharpen: opt.enableSharpen,
+                distance: opt.distance,
+                scale: opt.scale,
+                rotate: opt.rotate,
+              },
+              [imageBitMap]
+            )
+          })
+        })
       })
     },
-    { immediate: option.value.immediate }
+    { immediate: true }
   )
 
-  /** 渲染图片 */
-  watch([imageElement, () => option.value.scale], () => {
-    updateImageData(imageElement.value, option.value.scale || 1)
-  })
-
-  /** 获取主色 */
-  watch(rawContext, () => {
-    rawPixelPoints.value = []
-    rawMainColor.value = []
-    if (imageData.value && rawCanvas.value) {
-      const { pixelPoints, mainColor } = getMainColorWithPointes({
-        imageData: imageData.value,
-        canvas: rawCanvas.value,
-        basePoint: option.value.basePoint,
-      })
-      rawPixelPoints.value = pixelPoints
-      rawMainColor.value = mainColor
-    }
-  })
-
-  /** 锐化 */
-  watch([imageData, () => option.value.enableSharpen], () => {
-    if (imageData.value && option.value.enableSharpen) {
-      sharpenImage({ imageData: imageData.value, distance: option.value.distance || DISTANCE })
-      updateCanvas()
-    }
-  })
-
-  // /** 移除背景色 */
-  watch(rawMainColor, () => {
-    if (imageData.value && rawMainColor.value?.length) {
-      tintImage({
-        distance: option.value.distance || DISTANCE,
-        imageData: imageData.value,
-        mainColor: rawMainColor.value,
-      }).then((data: ImageData) => {
-        rawContext.value?.putImageData(data, 0, 0)
-        updateCanvas()
-      })
+  watch(
+    () => option.value.scale,
+    () => {
+      imageWorker.postMessage({ type: 'scale', scale: option.value?.scale })
     }
-  })
+  )
 
   watch(
     () => option.value.rotate,
     () => {
-      updateCanvas(option.value.rotate)
+      imageWorker.postMessage({ type: 'rotate', rotate: option.value?.rotate })
     }
   )
 
-  const updateCanvas = (rotate = option.value.rotate, immediate = false) => {
-    if (task) {
-      clearTimeout(task)
-    }
-    const update = () => {
-      if (rawCanvas.value) {
-        rawCanvas.value = canvasRotate(rawCanvas.value, rotate)
-        dataUrl.value = rawCanvas.value.toDataURL()
-      }
+  watch(dataUrl, (current, prev) => {
+    if (prev) {
+      try {
+        URL.revokeObjectURL(prev)
+      } catch (error) {}
     }
-    if (immediate) {
-      update()
-    } else {
-      task = window.setTimeout(update, 10)
+  })
+
+  onScopeDispose(() => {
+    if (dataUrl.value) {
+      try {
+        URL.revokeObjectURL(dataUrl.value)
+      } catch (error) {}
     }
-  }
+    imageWorker.terminate()
+  })
 
   return {
     drawing,
-    imageData,
-    pixelPoints: rawPixelPoints,
     dataUrl,
-    updateCanvas,
   }
 }

+ 0 - 1
src/modules/analysis/view-marked-detail/index.vue

@@ -196,7 +196,6 @@ const onSubmit = async () => {
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: current?.value?.filePath || '',
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 3 - 0
src/modules/bootstrap/login/index.vue

@@ -94,7 +94,10 @@ function loginSuccess(loginInfo: ExtractApiResponse<'userLogin'>) {
 
   mainStore.loginSuccessTime = Date.now()
 
+  mainStore.getUserMarkConfig()
+
   mainLayoutStore.getRenderMenuList()
+
   /**
    * 超级管理员每次登录完成之后需要选择考试批次
    * 其它角色如果是首次登录,需要去设置名称.

+ 0 - 1
src/modules/example/ImageModify.vue

@@ -75,7 +75,6 @@ const onOperationClick: OperationClick = ({ type, value }) => {
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: MockImg,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/expert/assess/index.vue

@@ -229,7 +229,6 @@ onOptionInit(onSearch)
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: currentAssessPaper?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/expert/expert/index.vue

@@ -292,7 +292,6 @@ onOptionInit(onSearch)
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: currentExpertPaper?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/expert/sample/index.vue

@@ -217,7 +217,6 @@ onOptionInit(onSearch)
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: currentRfPaper?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/expert/standard/index.vue

@@ -180,7 +180,6 @@ onOptionInit(onSearch)
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: currentStandardPaper?.value?.filePath,
-    immediate: true,
   }
 })
 

+ 0 - 1
src/modules/expert/training/index.vue

@@ -182,7 +182,6 @@ onOptionInit(onSearch)
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: current?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/marking/arbitration/index.vue

@@ -233,7 +233,6 @@ onOptionInit(onSearch)
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: currentArbitration?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/marking/inquiry-result/index.vue

@@ -306,7 +306,6 @@ const onSubmit = async () => {
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: current?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/marking/mark/index.vue

@@ -365,7 +365,6 @@ const onOperationClick: OperationClick = ({ type, value }) => {
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: currentTask?.value?.url,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/marking/problem/index.vue

@@ -258,7 +258,6 @@ onOptionInit(onSearch)
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: currentProblem?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/marking/repeat/index.vue

@@ -235,7 +235,6 @@ onOptionInit(onSearch)
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: currentReMarkPaper?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/marking/similar/index.vue

@@ -181,7 +181,6 @@ onOptionInit(onSearch)
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: currentSamePaper?.value?.filePath,
-    immediate: true,
   }
 })
 

+ 0 - 1
src/modules/marking/training-record/index.vue

@@ -128,7 +128,6 @@ viewTrainingRecord()
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: current?.value?.url,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/marking/view-sample/index.vue

@@ -105,7 +105,6 @@ viewSamplePaper()
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: current?.value?.url,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/monitor/system-check/index.vue

@@ -268,7 +268,6 @@ onOptionInit(onSearch)
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: currentSystemCheckPaper?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/monitor/training-monitoring-detail/index.vue

@@ -156,7 +156,6 @@ const {
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: current?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 18 - 1
src/modules/monitor/training-monitoring/hooks/useFormFilter.ts

@@ -4,7 +4,7 @@ import useForm from '@/hooks/useForm'
 import useOptions from '@/hooks/useOptions'
 import useVW from '@/hooks/useVW'
 
-import type { EpFormItem } from 'global-type'
+import type { EpFormItem, EpFormRules } from 'global-type'
 import type { ExtractApiParams } from '@/api/api'
 
 const useFormFilter = () => {
@@ -64,6 +64,22 @@ const useFormFilter = () => {
     { deep: true }
   )
 
+  watch(
+    [forceCheckGroup, model],
+    () => {
+      if (forceCheckGroup.value?.length && !model.forceGroupNumber) {
+        model.forceGroupNumber = forceCheckGroup.value[0].value
+      }
+    },
+    { deep: true, immediate: true }
+  )
+
+  const rules = computed<EpFormRules>(() => {
+    return {
+      forceGroupNumber: model.markStage === 'FORCE' ? [{ required: true, message: '请选择强制考核组' }] : [],
+    }
+  })
+
   const OneRowSpan5 = defineColumn(_, 'row-1', { span: 5 })
   const TwoRowSpan5 = defineColumn(_, 'row-2', { span: 5 })
 
@@ -143,6 +159,7 @@ const useFormFilter = () => {
     model,
     diffShow,
     items,
+    rules,
     onOptionInit,
   }
 }

+ 23 - 8
src/modules/monitor/training-monitoring/index.vue

@@ -1,7 +1,15 @@
 <template>
   <div class="flex direction-column full training-monitoring-view">
     <div class="p-l-base p-r-base p-t-medium-base fill-blank filter-form">
-      <base-form ref="formRef" size="small" :label-width="useVW(100)" :disabled="loading" :model="model" :items="items">
+      <base-form
+        ref="formRef"
+        size="small"
+        :label-width="useVW(100)"
+        :disabled="loading"
+        :model="model"
+        :rules="rules"
+        :items="items"
+      >
         <template #form-item-operation>
           <el-button type="primary" @click="onSearch">查询</el-button>
         </template>
@@ -63,7 +71,7 @@ type TableDataType = ExtractArrayValue<ExtractApiResponse<'getTrainingMonitor'>[
 
 const { push } = useRouter()
 
-const { diffShow, model, items, onOptionInit } = useFormFilter()
+const { diffShow, model, items, rules, formRef, elFormRef, onOptionInit } = useFormFilter()
 
 /** 培训监控列表 */
 const { fetch: getTrainingMonitor, result: trainingMonitor, loading } = useFetch('getTrainingMonitor')
@@ -109,12 +117,19 @@ const columns = computed<EpTableColumn<TableDataType>[]>(() => {
 let currentDataType: TableDataType['stage'] = 'SAMPLE_A'
 
 /** 刷新按钮 */
-function onSearch() {
-  const { diffShow, ...params } = model || {}
-  const dataType = model.markStage
-  getTrainingMonitor(params).then(() => {
-    currentDataType = dataType
-  })
+async function onSearch() {
+  try {
+    const valid = await elFormRef?.value?.validate()
+    if (valid) {
+      const { diffShow, ...params } = model || {}
+      const dataType = model.markStage
+      getTrainingMonitor(params).then(() => {
+        currentDataType = dataType
+      })
+    }
+  } catch (error) {
+    console.error(error)
+  }
 }
 
 /** 通过/不通过 */

+ 0 - 1
src/modules/quality/self-check-detail/index.vue

@@ -191,7 +191,6 @@ onRefresh()
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: current?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 0 - 1
src/modules/quality/subjective-check/index.vue

@@ -287,7 +287,6 @@ onOptionInit(onSearch)
 const imgOption = computed<SetImgBgOption>(() => {
   return {
     image: currentSubjectiveCheck?.value?.filePath,
-    immediate: true,
     rotate: rotate.value,
     scale: scale.value,
   }

+ 338 - 29
src/utils/image.worker.ts

@@ -1,54 +1,363 @@
-type RGBA = number[]
+export type RGBA = number[]
 
-interface TintImageOption {
-  imageData: ImageData
-  mainColor: RGBA
-  distance: number
+export type Point = [number, number]
+
+export interface GetPixPointParams {
+  width: number
+  height: number
+}
+
+export interface GetMainColorOption {
+  width: number
+  height: number
 }
 
+export interface InitTypeOption {
+  type: 'init'
+  imageBitMap: ImageBitmap
+  scale?: number
+  rotate?: number
+  distance?: number
+  enableSharpen?: boolean
+  basePoint?: Point[]
+}
+
+export interface scaleTypeOption {
+  type: 'scale'
+  scale: number
+}
+
+export interface rotateTypeOption {
+  type: 'rotate'
+  rotate: number
+}
+
+export type MessageData = InitTypeOption | scaleTypeOption | rotateTypeOption
+
+/**
+ * @description 相似度阀值
+ */
+const DISTANCE = 45
+
+/**
+ * @description 黑色RGBA
+ */
+const BLACK = [0, 0, 0, 255]
+
 /**
  * @description 白色RGBA
  */
-export const WHITE = [255, 255, 255, 255]
+const WHITE = [255, 255, 255, 255]
 
 /**
- * @description 相似度阀值
+ * @description 透明色RGBA
+ */
+const TRANSPARENT = [255, 255, 255, 0]
+
+/**
+ * @description 背景色取色点
+ */
+const COLOR_POINT: Point[] = [
+  [0, 0],
+  [0.25, 0],
+  [0.5, 0],
+  [0.75, 0],
+  [1, 0],
+  [0, 0.25],
+  [1, 0.25],
+  [0, 0.5],
+  [1, 0.5],
+  [0, 0.75],
+  [1, 0.75],
+  [0, 1],
+  [0.25, 1],
+  [0.5, 1],
+  [0.75, 1],
+]
+
+const pixelPointsHelper = [0, 1, 2, 3]
+
+/**
+ * @description 根据取色点获取图片像素点
+ * @params { Object } width canvas宽度
+ * @params { Object } height canvas高度
  */
-export const DISTANCE = 45
 
+const getPixelPoint = ({ width, height }: GetPixPointParams, colorPoint: Point[] = COLOR_POINT) => {
+  return colorPoint.map(([x, y]) => {
+    if (x <= 1 && y <= 1) {
+      const index = Math.max(Math.ceil(width * x), 0) + Math.max(Math.ceil(height * y) - 1, 0) * width
+      return Math.max(index + Math.ceil(x) * -1, 0) * 4
+    } else {
+      const index = Math.max(x, 0) + Math.max(y - 1, 0) * width - 1
+      return Math.max(index, 0) * 4
+    }
+  })
+}
+
+/**
+ * @description 计算像素点相似度
+ */
 const calcPixSim = (rgba: RGBA, base: RGBA = WHITE) => {
   const [r, g, b, a] = rgba
   const [r1, g1, b1, a1] = base
   return Math.sqrt((r - r1) ** 2 + (g - g1) ** 2 + (b - b1) ** 2 + (a - a1) ** 2)
 }
 
-const isSimilar = (color1: RGBA, color2: RGBA, distance: number = DISTANCE) => {
-  return calcPixSim(color1, color2) <= distance
+/**
+ * @description 获取各取色点中出现次数最多的颜色
+ */
+const getMainColor = (colors: RGBA[]) => {
+  let max = 1
+  let index = 0
+  let num = 0
+  const total = colors
+    .map((_) => calcPixSim(_))
+    .reduce((r, n, i) => {
+      r[n] ??= 0
+      r[n]++
+      if (r[n] > max) {
+        max = r[n]
+        num = n
+        index = i
+      }
+      return r
+    }, {} as Record<number, number>)
+  return {
+    max,
+    index,
+    num,
+    total,
+    mainColor: colors[index],
+  }
 }
 
-const getColorFromIndex = (index: number, data: Uint8ClampedArray) => {
-  return [0, 1, 2, 3].map((_) => {
-    return data[index + _]
-  })
+/**
+ * @description 遍历imageData
+ */
+const eachImageData = (data: ImageData, cb: (i: number) => void) => {
+  if (!data.data) {
+    return
+  }
+  for (let i = 0; i < data.data.length; i += 4) {
+    cb(i)
+  }
 }
 
-const setColorFromIndex = (index: number, data: Uint8ClampedArray, color: RGBA) => {
-  return [0, 1, 2, 3].forEach((_) => {
-    data[index + _] = color[_]
-  })
-}
+class ImageWorkerController {
+  canvas: OffscreenCanvas | null = null
+  #imageData: ImageData | null = null
+  #imageBitMap: ImageBitmap | null = null
+  #context: OffscreenCanvasRenderingContext2D | null = null
+  #distance: number = DISTANCE
+  #basePoint: Point[] = COLOR_POINT
+  #mainColor: RGBA | null = null
+  #scale = 1
+  #rotate = 0
+  #enableSharpen = false
+  #blob: Blob | null = null
+  #drawing = false
+  #initDone = false
+  #postTimer: number | null = null
 
-addEventListener('message', (e) => {
-  const imageData = tintImage(e.data)
-  postMessage(imageData)
-})
+  get drawing() {
+    return this.#drawing
+  }
 
-const tintImage = ({ mainColor, imageData, distance }: TintImageOption) => {
-  const targetColor = [255, 255, 255, 0]
-  for (let index = 0; index < imageData.data.length; index++) {
-    if (isSimilar(getColorFromIndex(index, imageData.data), mainColor, distance)) {
-      setColorFromIndex(index, imageData.data, targetColor)
+  set drawing(v: boolean) {
+    this.#drawing = v
+    this.post()
+  }
+
+  /**
+   * @description 判断两个颜色是否相似
+   */
+  isSimilar(color1: RGBA, color2: RGBA, distance: number = this.#distance) {
+    return calcPixSim(color1, color2) <= distance
+  }
+
+  /**
+   * @description 根据imageData起始下标获取rgba
+   */
+  getColorFromIndex(index: number) {
+    if (!this.#imageData) return []
+    return pixelPointsHelper.map((_) => {
+      return this.#imageData?.data[index + _]
+    }) as unknown as RGBA
+  }
+
+  /**
+   * @description 根据imageData起始下标设置rgba
+   */
+  setColorFromIndex = (index: number, color: RGBA) => {
+    if (!this.#imageData) return
+    for (const _ of pixelPointsHelper) {
+      this.#imageData.data[index + _] = color[_]
+    }
+  }
+
+  /** 获取主色 */
+  getMainColorWithPointes({ width, height }: GetMainColorOption) {
+    const pixelPoints = getPixelPoint({ width, height }, this.#basePoint)
+    const pixelsInfo = pixelPoints.map((_) => this.getColorFromIndex(_))
+    const { mainColor } = getMainColor(pixelsInfo)
+    return mainColor
+  }
+
+  /** 是否透明 */
+  isAlphaZero(color: RGBA) {
+    return color[-1] === 0
+  }
+
+  /** 锐化/移除背景色 */
+  async tintImage({ sharpen, tint }: { sharpen?: boolean; tint?: boolean }) {
+    if (!sharpen && !tint) {
+      return
+    }
+    if (!this.canvas) {
+      return
     }
+    if (!this.#imageData) {
+      return
+    }
+    const shouldTint = !!tint && !!this.#mainColor && !this.isAlphaZero(this.#mainColor)
+    eachImageData(this.#imageData, (index) => {
+      if (sharpen && this.isSimilar(this.getColorFromIndex(index), BLACK)) {
+        this.setColorFromIndex(index, BLACK)
+      }
+      if (shouldTint && this.isSimilar(this.getColorFromIndex(index), this.#mainColor as RGBA)) {
+        this.setColorFromIndex(index, TRANSPARENT)
+      }
+    })
+    this.#context?.putImageData(this.#imageData, 0, 0)
+    this.#imageBitMap = this.canvas.transferToImageBitmap()
+    this.imageToCanvas(1)
+  }
+
+  /** 缩放 */
+  async scaleImage(scale: number) {
+    if (!this.#initDone) return
+    this.#scale = scale ?? this.#scale
+    await this.imageToCanvas(scale)
+    if (this.#rotate !== 0) {
+      this.rotateImage(this.#rotate)
+    }
+  }
+
+  /** 旋转 */
+  async rotateImage(rotate = this.#rotate) {
+    this.#rotate = rotate % 360
+    if (!this.#initDone) return
+    if (!this.canvas) {
+      return
+    }
+    if (this.#rotate === 0) {
+      return this.imageToCanvas(this.#scale)
+    }
+    const { width, height } = this.canvas
+    const W = Math.max(width, height)
+    const canvas = new OffscreenCanvas(W, W)
+    const context = canvas.getContext('2d')
+
+    if (!context) {
+      return
+    }
+    this.drawing = true
+    context.translate(W / 2, W / 2)
+    context.rotate(this.#rotate * (Math.PI / 180))
+    let x = -W / 2
+    let y = -W / 2
+    let w = width
+    let h = height
+    switch (this.#rotate) {
+      case 90:
+        y = W / 2 - height
+        w = height
+        h = width
+        break
+      case 180:
+        x = W / 2 - width
+        y = W / 2 - height
+        break
+      case 270:
+        x = -width + W / 2
+        w = height
+        h = width
+        break
+    }
+    context.drawImage(this.canvas, x, y)
+    const renderCanvas = new OffscreenCanvas(w, h)
+    const renderContext = renderCanvas.getContext('2d')
+    renderContext?.drawImage(canvas, 0, 0, w, h, 0, 0, w, h)
+    this.#blob = await renderCanvas?.convertToBlob()
+    this.drawing = false
+  }
+
+  async imageToCanvas(scale = 1, imageBitMap: ImageBitmap | null = this.#imageBitMap) {
+    if (!imageBitMap) {
+      return
+    }
+    this.drawing = true
+    const width = imageBitMap.width * scale
+    const height = imageBitMap.height * scale
+    this.canvas = new OffscreenCanvas(width, height)
+    this.#context = this.canvas.getContext('2d')
+    this.#context?.drawImage(imageBitMap, 0, 0, width, height)
+    this.#imageData = this.#context?.getImageData(0, 0, width, height) || null
+    this.#blob = await this.canvas.convertToBlob()
+    this.drawing = false
+  }
+
+  async imageDataUpdate() {
+    if (this.#imageData && this.canvas) {
+      this.#mainColor = this.getMainColorWithPointes({ width: this.canvas.width, height: this.canvas.height })
+      await this.tintImage({ sharpen: this.#enableSharpen, tint: true })
+    }
+  }
+
+  async init(option: InitTypeOption) {
+    if (!option.imageBitMap) return
+    this.#initDone = false
+    this.#imageBitMap = option.imageBitMap
+    this.#basePoint = option.basePoint ?? COLOR_POINT
+    this.#enableSharpen = option.enableSharpen ?? false
+    this.#distance = option.distance ?? DISTANCE
+    this.#scale = option.scale ?? 1
+    this.#rotate = option.rotate ?? 0
+    await this.imageToCanvas(1)
+    await this.imageDataUpdate()
+    this.#initDone = true
+    if (this.#scale !== 1) {
+      this.scaleImage(this.#scale)
+    } else if (this.#rotate !== 0) {
+      this.rotateImage(this.#rotate)
+    }
+  }
+
+  post() {
+    if (this.#postTimer) {
+      clearTimeout(this.#postTimer)
+    }
+    this.#postTimer = self.setTimeout(() => {
+      postMessage({ drawing: this.drawing, blob: this.#blob })
+    }, 20)
   }
-  return imageData
 }
+
+const imageController = new ImageWorkerController()
+
+addEventListener('message', (e) => {
+  const data = e.data as MessageData
+
+  switch (data.type) {
+    case 'init':
+      imageController.init(data)
+      break
+    case 'scale':
+      imageController.scaleImage(data.scale)
+      break
+    case 'rotate':
+      imageController.rotateImage(data.rotate)
+      break
+  }
+})

+ 1 - 0
tsconfig.json

@@ -29,6 +29,7 @@
     },
     "types": [
       "vite-plugin-svg-icons/client",
+      "@types/offscreencanvas"
     ]
   },
   "include": [