Repository: fengzhizi715/Monica Branch: main Commit: f110bba4c7ad Files: 351 Total size: 1.7 MB Directory structure: gitextract_0i8k_7pw/ ├── .gitattributes ├── .gitignore ├── CHANGELOG.md ├── FUNCTION.md ├── LICENSE ├── README-EN.md ├── README.md ├── build.gradle.kts ├── compose-desktop.pro ├── config/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── cn/ │ └── netdiscovery/ │ └── monica/ │ └── config/ │ ├── Constants.kt │ ├── SystemConstants.kt │ ├── category/ │ │ ├── ConfigCategory.kt │ │ ├── ConfigCategoryManager.kt │ │ ├── ConfigDefinitions.kt │ │ └── ConfigValidator.kt │ └── storage/ │ ├── ConfigManager.kt │ ├── ConfigStorage.kt │ ├── FileConfigStorage.kt │ ├── PreferencesConfigStorage.kt │ └── RxCacheConfigStorage.kt ├── docs/ │ ├── filter_module_refactor.md │ ├── layer_render_cache_analysis.md │ ├── layer_system.md │ └── layer_system_optimization_roadmap.md ├── domain/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── cn/ │ └── netdiscovery/ │ └── monica/ │ └── domain/ │ ├── ColorCorrectionSettings.kt │ ├── ContourDisplaySettings.kt │ ├── ContourFilterSettings.kt │ ├── DecodedPreviewImage.kt │ ├── GeneralSettings.kt │ ├── MatchTemplateSettings.kt │ ├── MorphologicalOperationSettings.kt │ └── NativeImage.kt ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── i18n/ │ ├── QUICK_REFERENCE.md │ ├── README.md │ ├── SCRIPT_USAGE_GUIDE.md │ ├── build.gradle.kts │ ├── position_check.sh │ ├── quick_check.sh │ ├── src/ │ │ ├── main/ │ │ │ ├── kotlin/ │ │ │ │ └── cn/ │ │ │ │ └── netdiscovery/ │ │ │ │ └── monica/ │ │ │ │ └── i18n/ │ │ │ │ ├── Language.kt │ │ │ │ ├── LocalizationManager.kt │ │ │ │ └── XmlStringResource.kt │ │ │ └── resources/ │ │ │ └── strings/ │ │ │ ├── strings_en.xml │ │ │ └── strings_zh.xml │ │ └── test/ │ │ └── kotlin/ │ │ └── cn/ │ │ └── netdiscovery/ │ │ └── monica/ │ │ └── i18n/ │ │ └── InternationalizationTest.kt │ └── string_manager.sh ├── imageprocess/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── cn/ │ └── netdiscovery/ │ └── monica/ │ └── imageprocess/ │ ├── BufferedImages.kt │ ├── Colormap.kt │ ├── IntIntegralImage.kt │ ├── Transformer.kt │ ├── domain/ │ │ ├── ArrayColormap.kt │ │ ├── Gradient.kt │ │ └── Histogram.kt │ ├── filter/ │ │ ├── BilateralFilter.kt │ │ ├── BlockFilter.kt │ │ ├── BumpFilter.kt │ │ ├── CarveFilter.kt │ │ ├── CellularFilter.kt │ │ ├── ColorFilter.kt │ │ ├── ColorHalftoneFilter.kt │ │ ├── ConBriFilter.kt │ │ ├── CropFilter.kt │ │ ├── CrystallizeFilter.kt │ │ ├── DiffuseFilter.kt │ │ ├── EmbossFilter.kt │ │ ├── EqualizeFilter.kt │ │ ├── ExposureFilter.kt │ │ ├── GainFilter.kt │ │ ├── GammaFilter.kt │ │ ├── GaussianNoiseFilter.kt │ │ ├── GradientFilter.kt │ │ ├── GrayFilter.kt │ │ ├── HSBAdjustFilter.kt │ │ ├── HighPassFilter.kt │ │ ├── InvertFilter.kt │ │ ├── MarbleFilter.kt │ │ ├── MirrorFilter.kt │ │ ├── MosaicFilter.kt │ │ ├── NatureFilter.kt │ │ ├── OffsetFilter.kt │ │ ├── OilPaintFilter.kt │ │ ├── PointillizeFilter.kt │ │ ├── PosterizeFilter.kt │ │ ├── RippleFilter.kt │ │ ├── SepiaToneFilter.kt │ │ ├── SmearFilter.kt │ │ ├── SolarizeFilter.kt │ │ ├── SpotlightFilter.kt │ │ ├── StrokeAreaFilter.kt │ │ ├── SwimFilter.kt │ │ ├── VignetteFilter.kt │ │ ├── WaterFilter.kt │ │ ├── WhiteImageFilter.kt │ │ ├── base/ │ │ │ ├── BaseFilter.kt │ │ │ ├── ColorProcessorFilter.kt │ │ │ ├── ConvolveFilter.kt │ │ │ ├── PointFilter.kt │ │ │ ├── TransferFilter.kt │ │ │ ├── TransformFilter.kt │ │ │ └── WholeImageFilter.kt │ │ ├── blur/ │ │ │ ├── AverageFilter.kt │ │ │ ├── BoxBlurFilter.kt │ │ │ ├── FastBlur2D.kt │ │ │ ├── GaussianFilter.kt │ │ │ ├── LensBlurFilter.kt │ │ │ ├── MaximumFilter.kt │ │ │ ├── MinimumFilter.kt │ │ │ ├── MotionFilter.kt │ │ │ └── VariableBlurFilter.kt │ │ └── sharpen/ │ │ ├── LaplaceSharpenFilter.kt │ │ ├── SharpenFilter.kt │ │ └── USMFilter.kt │ ├── lut/ │ │ ├── AutumnLUT.kt │ │ ├── BoneLUT.kt │ │ ├── CoolLUT.kt │ │ ├── HotLUT.kt │ │ ├── HsvLUT.kt │ │ ├── JetLUT.kt │ │ ├── LUT.kt │ │ ├── OceanLUT.kt │ │ ├── PinkLUT.kt │ │ ├── RainbowLUT.kt │ │ ├── SpringLUT.kt │ │ ├── SummerLUT.kt │ │ └── WinterLUT.kt │ ├── math/ │ │ ├── FFT.kt │ │ ├── Functions.kt │ │ ├── ImageMath.kt │ │ └── Noise.kt │ └── utils/ │ ├── ImageUtils.kt │ └── extension/ │ └── BufferedImage+Extensions.kt ├── opencv/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── cn/ │ └── netdiscovery/ │ └── monica/ │ └── opencv/ │ └── ImageProcess.kt ├── resources/ │ ├── common/ │ │ └── filterConfig.json │ ├── package.json │ └── web-screenshot.js ├── settings.gradle.kts └── src/ ├── jvmMain/ │ ├── kotlin/ │ │ ├── Main.kt │ │ └── cn/ │ │ └── netdiscovery/ │ │ └── monica/ │ │ ├── config/ │ │ │ └── Constant.kt │ │ ├── di/ │ │ │ └── appModule.kt │ │ ├── exception/ │ │ │ ├── AppError.kt │ │ │ ├── ErrorComposable.kt │ │ │ ├── ErrorExtensions.kt │ │ │ ├── ErrorHandler.kt │ │ │ ├── ErrorManager.kt │ │ │ ├── ErrorState.kt │ │ │ ├── Errors.kt │ │ │ ├── MonicaException.kt │ │ │ └── handlers/ │ │ │ ├── AIServiceErrorHandler.kt │ │ │ ├── FileIOErrorHandler.kt │ │ │ ├── ImageProcessingErrorHandler.kt │ │ │ ├── NetworkErrorHandler.kt │ │ │ └── ValidationErrorHandler.kt │ │ ├── history/ │ │ │ ├── EditHistoryCenter.kt │ │ │ ├── EditHistoryManager.kt │ │ │ ├── HistoryEntry.kt │ │ │ └── modules/ │ │ │ ├── colorcorrection/ │ │ │ │ └── ColorCorrectionParams.kt │ │ │ └── opencv/ │ │ │ └── CVParams.kt │ │ ├── http/ │ │ │ ├── GsonSerializer.kt │ │ │ └── HttpClient.kt │ │ ├── llm/ │ │ │ ├── DeepSeekRequest.kt │ │ │ ├── DeepseekClient.kt │ │ │ ├── DialogSession.kt │ │ │ ├── GeminiClient.kt │ │ │ ├── GeminiRequest.kt │ │ │ └── LLMServiceManager.kt │ │ ├── manager/ │ │ │ └── OpenCVManager.kt │ │ ├── state/ │ │ │ └── ApplicationState.kt │ │ ├── ui/ │ │ │ ├── controlpanel/ │ │ │ │ ├── BasicView.kt │ │ │ │ ├── ai/ │ │ │ │ │ ├── AIView.kt │ │ │ │ │ ├── AIViewModel.kt │ │ │ │ │ ├── experiment/ │ │ │ │ │ │ ├── BinaryImageView.kt │ │ │ │ │ │ ├── CVState.kt │ │ │ │ │ │ ├── ContourAnalysisView.kt │ │ │ │ │ │ ├── EdgeDetectionView.kt │ │ │ │ │ │ ├── ExperimentHome.kt │ │ │ │ │ │ ├── ExperimentView.kt │ │ │ │ │ │ ├── HistoryView.kt │ │ │ │ │ │ ├── ImageDenoisingView.kt │ │ │ │ │ │ ├── ImageEnhanceView.kt │ │ │ │ │ │ ├── MatchTemplateView.kt │ │ │ │ │ │ ├── MorphologicalOperationsView.kt │ │ │ │ │ │ ├── NavController.kt │ │ │ │ │ │ ├── NavigationHost.kt │ │ │ │ │ │ └── viewmodel/ │ │ │ │ │ │ ├── BinaryImageViewModel.kt │ │ │ │ │ │ ├── ContourAnalysisViewModel.kt │ │ │ │ │ │ ├── EdgeDetectionViewModel.kt │ │ │ │ │ │ ├── HistoryViewModel.kt │ │ │ │ │ │ ├── ImageDenoisingViewModel.kt │ │ │ │ │ │ ├── ImageEnhanceViewModel.kt │ │ │ │ │ │ ├── MatchTemplateViewModel.kt │ │ │ │ │ │ └── MorphologicalOperationsViewModel.kt │ │ │ │ │ └── faceswap/ │ │ │ │ │ ├── FaceSwapView.kt │ │ │ │ │ └── FaceSwapViewModel.kt │ │ │ │ ├── cartoon/ │ │ │ │ │ ├── CartoonView.kt │ │ │ │ │ └── CartoonViewModel.kt │ │ │ │ ├── colorcorrection/ │ │ │ │ │ ├── ColorCorrectionView.kt │ │ │ │ │ ├── ColorCorrectionViewModel.kt │ │ │ │ │ └── NaturalLanguageDialog.kt │ │ │ │ ├── colorpick/ │ │ │ │ │ ├── ColorPickView.kt │ │ │ │ │ ├── model/ │ │ │ │ │ │ ├── ColorData.kt │ │ │ │ │ │ ├── ColorNameMap.kt │ │ │ │ │ │ └── ColorNameParser.kt │ │ │ │ │ ├── utils/ │ │ │ │ │ │ ├── ColorDetection.kt │ │ │ │ │ │ ├── ColorUtils.kt │ │ │ │ │ │ └── RoundngUtils.kt │ │ │ │ │ └── widget/ │ │ │ │ │ ├── ColorDisplay.kt │ │ │ │ │ ├── ColorSelectionDrawing.kt │ │ │ │ │ └── ImageColorDetector.kt │ │ │ │ ├── compression/ │ │ │ │ │ ├── CompressionActions.kt │ │ │ │ │ ├── CompressionAlgorithmDropdown.kt │ │ │ │ │ ├── CompressionInputSection.kt │ │ │ │ │ ├── CompressionPreview.kt │ │ │ │ │ ├── CompressionProgress.kt │ │ │ │ │ ├── CompressionSliders.kt │ │ │ │ │ ├── CompressionView.kt │ │ │ │ │ └── CompressionViewModel.kt │ │ │ │ ├── cropimage/ │ │ │ │ │ ├── CropAgent.kt │ │ │ │ │ ├── CropImageSettingView.kt │ │ │ │ │ ├── CropImageView.kt │ │ │ │ │ ├── CropModifier.kt │ │ │ │ │ ├── CropViewModel.kt │ │ │ │ │ ├── ImageCropper.kt │ │ │ │ │ ├── TouchRegion.kt │ │ │ │ │ ├── draw/ │ │ │ │ │ │ ├── ImageDrawCanvas.kt │ │ │ │ │ │ └── Overlay.kt │ │ │ │ │ ├── model/ │ │ │ │ │ │ ├── CropAspectRatio.kt │ │ │ │ │ │ ├── CropFrame.kt │ │ │ │ │ │ ├── CropOutline.kt │ │ │ │ │ │ ├── CropOutlineContainer.kt │ │ │ │ │ │ └── CropOutlineProperties.kt │ │ │ │ │ ├── setting/ │ │ │ │ │ │ ├── CropDefaults.kt │ │ │ │ │ │ ├── CropFrameFactory.kt │ │ │ │ │ │ └── Paths.kt │ │ │ │ │ ├── state/ │ │ │ │ │ │ ├── CropState.kt │ │ │ │ │ │ ├── CropStateImpl.kt │ │ │ │ │ │ ├── DynamicCropState.kt │ │ │ │ │ │ ├── StaticCropState.kt │ │ │ │ │ │ └── TransformState.kt │ │ │ │ │ └── utils/ │ │ │ │ │ ├── DrawScopeUtils.kt │ │ │ │ │ ├── ShapeUtils.kt │ │ │ │ │ └── ZoomUtils.kt │ │ │ │ ├── doodle/ │ │ │ │ │ ├── DoodleView.kt │ │ │ │ │ ├── DoodleViewModel.kt │ │ │ │ │ ├── model/ │ │ │ │ │ │ └── PathProperties.kt │ │ │ │ │ └── widget/ │ │ │ │ │ └── PropertiesMenuDialog.kt │ │ │ │ ├── filter/ │ │ │ │ │ ├── FilterView.kt │ │ │ │ │ ├── viewmodel/ │ │ │ │ │ │ └── FilterViewModel.kt │ │ │ │ │ └── widget/ │ │ │ │ │ ├── FilterAdjustmentPanel.kt │ │ │ │ │ ├── FilterListPanel.kt │ │ │ │ │ ├── FilterParamDefaults.kt │ │ │ │ │ ├── FilterParamMeta.kt │ │ │ │ │ ├── FilterPreviewArea.kt │ │ │ │ │ └── FilterTopAppBar.kt │ │ │ │ ├── generategif/ │ │ │ │ │ ├── GenerateGifView.kt │ │ │ │ │ └── GenerateGifViewModel.kt │ │ │ │ ├── shapedrawing/ │ │ │ │ │ ├── CoordinateSystem.kt │ │ │ │ │ ├── EditorController.kt │ │ │ │ │ ├── ShapeDrawingView.kt │ │ │ │ │ ├── ShapeDrawingViewModel.kt │ │ │ │ │ ├── animation/ │ │ │ │ │ │ └── ShapeAnimationManager.kt │ │ │ │ │ ├── coordinate/ │ │ │ │ │ │ └── CoordinateConverter.kt │ │ │ │ │ ├── geometry/ │ │ │ │ │ │ ├── CanvasDrawer.kt │ │ │ │ │ │ ├── Drawer.kt │ │ │ │ │ │ └── Style.kt │ │ │ │ │ ├── handler/ │ │ │ │ │ │ └── ShapeDrawingEventHandler.kt │ │ │ │ │ ├── helper/ │ │ │ │ │ │ └── SpecialLayerHelper.kt │ │ │ │ │ ├── layer/ │ │ │ │ │ │ ├── ImageLayer.kt │ │ │ │ │ │ ├── Layer.kt │ │ │ │ │ │ ├── LayerManager.kt │ │ │ │ │ │ ├── LayerRenderer.kt │ │ │ │ │ │ ├── ShapeLayer.kt │ │ │ │ │ │ └── SpecialLayerHelper.kt │ │ │ │ │ ├── model/ │ │ │ │ │ │ ├── Shape.kt │ │ │ │ │ │ └── ShapeProperties.kt │ │ │ │ │ ├── state/ │ │ │ │ │ │ └── ShapeDrawingState.kt │ │ │ │ │ └── widget/ │ │ │ │ │ ├── CanvasView.kt │ │ │ │ │ ├── DraggableTextField.kt │ │ │ │ │ ├── ImageLayerControlRenderer.kt │ │ │ │ │ ├── LayerPanel.kt │ │ │ │ │ ├── ShapeDrawingPropertiesMenuDialog.kt │ │ │ │ │ └── TextDrawer.kt │ │ │ │ └── webscreenshot/ │ │ │ │ ├── WebScreenshotView.kt │ │ │ │ └── WebScreenshotViewModel.kt │ │ │ ├── i18n/ │ │ │ │ └── ComposeI18n.kt │ │ │ ├── main/ │ │ │ │ ├── ContentPanel.kt │ │ │ │ ├── Dialogs.kt │ │ │ │ ├── GeneralSettingsDialog.kt │ │ │ │ ├── MainView.kt │ │ │ │ ├── MainViewModel.kt │ │ │ │ └── SidebarView.kt │ │ │ ├── preview/ │ │ │ │ ├── PreviewViewModel.kt │ │ │ │ └── PreviewViewt.kt │ │ │ ├── screenshot/ │ │ │ │ └── SwingScreenshotAreaSelector.kt │ │ │ ├── showimage/ │ │ │ │ └── ShowImageView.kt │ │ │ ├── theme/ │ │ │ │ ├── ColorTheme.kt │ │ │ │ └── ThemeManager.kt │ │ │ └── widget/ │ │ │ ├── Buttons.kt │ │ │ ├── Checkboxs.kt │ │ │ ├── Divider.kt │ │ │ ├── LazyRow.kt │ │ │ ├── PageLifecycle.kt │ │ │ ├── RightSideMenuBar.kt │ │ │ ├── TextFields.kt │ │ │ ├── ThreeBallLoading.kt │ │ │ ├── Title.kt │ │ │ ├── Toasts.kt │ │ │ ├── color/ │ │ │ │ ├── ColorSelection.kt │ │ │ │ └── ColorSelectionDialog.kt │ │ │ ├── image/ │ │ │ │ ├── ImageContentScaleUtil.kt │ │ │ │ ├── ImageScope.kt │ │ │ │ ├── ImageSizeCalculator.kt │ │ │ │ ├── ImageWithConstraints.kt │ │ │ │ ├── ImageWithThumbnail.kt │ │ │ │ └── gesture/ │ │ │ │ ├── AwaitDragMotionModifier.kt │ │ │ │ ├── AwaitPointerMontionEvent.kt │ │ │ │ ├── MotionEvent.kt │ │ │ │ ├── PointerMotionModify.kt │ │ │ │ └── TransformGestures.kt │ │ │ └── properties/ │ │ │ └── ExposedSelectionMenu.kt │ │ └── utils/ │ │ ├── AppDirs.kt │ │ ├── ButtonUtils.kt │ │ ├── DebugUtils.kt │ │ ├── FileChoose.kt │ │ ├── IOUtils.kt │ │ ├── ImageCompressionUtils.kt │ │ ├── ImageFormatDetector.kt │ │ ├── ImageUtils.kt │ │ ├── LogHomeProperty.kt │ │ ├── LogUtils.kt │ │ ├── ScreenshotUtils.kt │ │ ├── TextUtils.kt │ │ ├── TimeUtils.kt │ │ ├── Typealiases.kt │ │ ├── Validation.kt │ │ ├── WebScreenshotUtils.kt │ │ └── extensions/ │ │ ├── Any+Extensions.kt │ │ ├── Coroutine+Extensions.kt │ │ ├── DrawScope+Extensions.kt │ │ ├── Number+Extensions.kt │ │ └── String+Extensions.kt │ └── resources/ │ └── logback.xml └── jvmTest/ └── kotlin/ └── cn/ └── netdiscovery/ └── monica/ └── editor/ └── layer/ ├── ExportManagerTest.kt └── LayerManagerTest.kt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ # Auto detect text files and perform LF normalization * text=auto ================================================ FILE: .gitignore ================================================ .gradle build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ bin/ config/bin/ domain/bin/ i18n/bin/ imageprocess/bin/ opencv/bin/ ### IntelliJ IDEA ### .idea/ *.iws *.iml *.ipr out/ !**/src/main/**/out/ !**/src/test/**/out/ ### VS Code ### .vscode/ ### Mac OS ### .DS_Store .idea .kotlin # cache rxcache/ # log log/ # spec .cursor/ .specify ================================================ FILE: CHANGELOG.md ================================================ Monica === Version 1.1.4 --- 2025-9-25 * 增加使用 Gemini 实现自然语言实现调色的功能 * 增加主题的功能,Monica 可以切换主题 * 增加国际化,支持英文版本 * 重构 Monica UI 的首页 * 重构图像绘制形状模块 * 重构涂鸦模块 Version 1.1.3 --- 2025-8-4 * 增加使用自然语言实现调色的功能 * 增加 OpenCV 调参过程的记录 Version 1.1.2 --- 2025-7-28 * 优化图像调色的代码(使用图像金字塔优化raw、heif文件的加载和调色) Version 1.1.1 --- 2025-7-15 * 优化 jni 的代码 * 优化图像调色的代码(使用并行 + LUT) Version 1.1.0 --- 2025-6-25 * 在 macOS 上从零构建 libheif + OpenCV 图像处理库,而不是依赖 brew 安装的库 * 优化代码 Version 1.0.9 --- 2025-6-6 * 增加多种格式的导入导出 * 修复 macOS 打包安装失败的 bug Version 1.0.8 --- 2025-4-20 * 修复保存 png 图像出错的 bug Version 1.0.7 --- 2025-4-18 * 优化图片的加载过程 * 支持使用 GPU 来推理(前提是需要支持CUDA) * 增加生成多种风格的漫画 Version 1.0.6 --- 2025-4-8 * 滤镜数量增加到50多款 * 模型的调用从调用本地算法迁移到通过 http 调用算法服务,减少软件对模型文件的依赖。 * 增加软件的通用设置 * Kotlin 版本升级到 2.1.0 Version 1.0.5 --- 2025-3-6 * 增加将多张图片生成 gif 的功能 * 优化滤镜相关的配置 Version 1.0.4 --- 2025-1-23 * 完善对 CV 算法快速调参的模块 Version 1.0.3 --- 2024-11-27 * 修复图像调色的 bug Version 1.0.2 --- 2024-11-26 * 增加形状绘制和添加文字 * 增加图像调色 * 完善 Monica 常用的组件 Version 1.0.1 --- 2024-11-3 * 增加对 CV 算法快速调参的模块 * 完善 Monica 常用的组件 Version 1.0.0 --- 2024-9-18 * Kotlin 版本升到 2.0.20 * 增加图像错切 * 增加多种图像增强的算法 * 增加深度学习相关的功能(人脸检测、生成素描画、替换人脸) Version 0.2.6 --- 2024-7-18 * 在 MacOS(只针对Intel 芯片)增加多种图像增强的算法,通过 OpenCV C++ 实现 Version 0.2.5 --- 2024-7-13 * 增加 NatureFilter 滤镜 * 增加 logback 作为日志框架 Version 0.2.4 --- 2024-6-30 * 增加 FastBlur2D 滤镜 * 增加图像裁剪的属性 Version 0.2.3 --- 2024-6-17 * 增加图片取色功能 * 增加 ColorFilter * 优化架构 Version 0.2.2 --- 2024-6-13 * 完善图像的裁剪功能 Version 0.2.1 --- 2024-6-9 * 优化裁剪的 UI * 优化滤镜相关的架构 Version 0.2.0 --- 2024-5-29 * 增加图像的裁剪功能 * 增加 VignetteFilter * 增加 toast 提示 Version 0.1.5 --- 2024-5-25 * 升级 koin 版本 * 优化图像的涂鸦功能 * 增加 StrokeAreaFilter Version 0.1.4 --- 2024-5-24 * 增加图像的涂鸦功能 Version 0.1.3 --- 2024-5-11 * 增加图像的 resize 功能 * 增加带 tooltip 的按钮 Version 0.1.2 --- 2024-5-9 * 增加 EmbossFilter、OilPaintFilter * 修复某种情况下无法保存图像的 bug Version 0.1.1 --- 2024-5-8 * 增加图像的 flip、rotate 功能 * 引入 koin 作为依赖注入的容器 Version 0.1.0 --- 2024-5-6 * 提供加载本地图片、网络图片。 * 对图片局部模糊、打马赛克。 * 调整图片的饱和度、色相、亮度。 * 提供 20 款滤镜,大多数滤镜也可以单独调整参数。 * 对修改的图像进行保存。 * 放大、缩小图像。 ================================================ FILE: FUNCTION.md ================================================ ## 2.1 基础功能 加载完图像后,就可以对图像进行各种编辑和操作 ![](images/1-1.png) Monica 基础功能的按钮,都带有 tooltips ,例如这个涂鸦功能 ![](images/1-2.png) 点击按钮就可以进入涂鸦界面,对图像进行随意的涂鸦。 ![](images/1-3.png) 由于 Monica 是一款桌面软件,画笔由鼠标进行控制。画笔默认是黑色的,可以随着鼠标的移动而进行绘制曲线。Monica 支持选择画笔的颜色,以及选择画笔的粗细。 ![](images/1-4.png) ![](images/1-5.png) 涂鸦完之后,记得保存图片,这样回到主界面之后才真正的保存结果了。 ![](images/1-6.png) 在基础功能里,还有一个比较有意思的功能,对图像取色 ![](images/1-7.png) 这个功能通过点击图像中的位置,获取颜色相关的信息,包括 HEX 颜色代码值、RGB 值、HSL 值和 HSV 值。 ![](images/1-8.png) ![](images/1-9.png) ## 2.2 裁剪 基础功能有个比较强大的功能——裁剪 ,通过点击带提示的裁剪按钮 ![](images/2-1.png) 可以进入图像裁剪的界面 ![](images/2-2.png) 用户可以基于九宫格的选框,对图像进行裁剪。 ![](images/2-3.png) ![](images/2-4.png) 裁剪完之后,会在主界面显示截取之后的图像。 ![](images/2-5.png) 当然,这只是最基本的裁剪功能,Monica 可以通过设置裁剪属性支持多种形式的裁剪。 ![](images/2-6.png) 下面,我们以正六边形为裁剪框来裁剪图像 ![](images/2-7.png) ![](images/2-8.png) 接下来,还可以以爱心为裁剪框来裁剪图像 ![](images/2-9.png) ![](images/2-10.png) ## 2.3 图像绘制 形状绘制的入口 ![](images/3-1.png) 绘制形状的页面 ![](images/3-2.png) Monica 提供了图像上的任意位置绘制各种图形的功能,以及修改图形的属性比如图像的颜色、透明度、是否填充、边框类型。 ![](images/3-3.png) 保存图像 ![](images/3-4.png) ## 2.4 图像调色 Monica 支持调节图像的对比度、色调、饱和度、亮度、色温等,从而帮助大家调整图像的色彩。 图像调色的入口 ![](images/4-1.png) 图像调色的界面 ![](images/4-2.png) 支持拖动调节各个参数 ![](images/4-3.png) ![](images/4-4.png) 保存图像 ![](images/4-5.png) Monica 还支持通过 LLM 进行调色,主要是 deepseek、 gemini。用户每次输入一句自然语言指令,比如“肤色偏黄,冷一点”,就可以进行调色。也支持多轮对话和切换不同的大模型。 ![](images/4-6.png) ![](images/4-7.png) ![](images/4-8.png) ![](images/4-9.png) ![](images/4-10.png) ## 2.5 滤镜 Monica 支持多达 50 多款滤镜,大多数可以自行调整参数。 ![](images/5-1.png) 如果需要修改滤镜的默认参数,可以直接修改。 ![](images/5-2.png) ![](images/5-3.png) ![](images/5-4.png) ![](images/5-5.png) ![](images/5-6.png) ![](images/5-7.png) ![](images/5-8.png) ![](images/5-9.png) ![](images/5-10.png) 各种滤镜效果可以不断叠加,也可以跟其他功能一起使用。 ## 2.6 深度学习的算法 在 AI 实验室有一些比较有意思的算法,比如人脸检测、生成素描画、人脸替换 人脸检测包括:人脸、年龄、性别检测 ![](images/6-1.png) 生成素描画的效果 ![](images/6-2.png) 人脸替换 ![](images/6-3.png) "人脸替换"需要一张源图和加载一张目标图片。 ![](images/6-4.png) ![](images/6-5.png) ![](images/6-6.png) "人脸替换"也支持将目标图中所有的人脸进行替换。 ![](images/6-7.png) 只需要设置一下替换 target 中人脸的数量即可。 ![](images/6-8.png) 就可以完成目标图中所有的人脸替换。 ![](images/6-9.png) ## 2.7 快速验证 OpenCV 算法的功能 快速验证 OpenCV 算法的功能,更像是一个简单的快速调试开发工具,我做它的目的是为了快速验证一些算法,未来也不排除会把这个模块独立出来。 下面是该模块的入口 ![](images/7-1.png) 以及该模块的首页 ![](images/7-2.png) ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README-EN.md ================================================ **Monica** is a cross-platform desktop image editor. It supports a wide range of image formats (including camera RAW), integrates both traditional image processing and deep learning–based image enhancement, and provides an extensible, developer-friendly editing experience. # 🧪 Tech Stack * **UI Framework**: Kotlin Compose Multiplatform (Desktop) * **Image Processing**: OpenCV * **Deep Learning Inference**: ONNX Runtime * **Backend Languages**: Kotlin / C++ * **Build Tools**: Gradle / CMake # ✨ Features ## 📷 Image Editing * Import: JPG, PNG, WebP, SVG, HDR, HEIC * Import camera RAW files: CR2, CR3, etc. * Export: JPG, PNG, WebP * Zoom and preview * Local blur & mosaic * Freehand drawing, shapes, and text annotations * Color picker * Geometric transforms: flip, rotate, scale, shear * Cropping with multiple shapes * Adjustments: contrast, hue, saturation, brightness, temperature, highlights, shadows * 50+ adjustable filters * Multi-image → GIF creation * Quick validation of OpenCV algorithms with parameter tuning ## 🤖 AI-powered Enhancements * Face detection (face, gender, age) * Sketch generation from photos * Face replacement * Cartoonization with multiple styles # 📦 Installation & Usage ## Run from Source Use IntelliJ IDEA / IntelliJ IDEA CE ```bash git clone https://github.com/fengzhizi715/Monica.git cd Monica ./gradlew run ``` ## Packaging Recommended packaging command: ```bash ./gradlew packageCurrentOsWithBundledWebRuntime ``` Notes: * Local development defaults to `isProVersion=false` * Packaging tasks automatically switch to `isProVersion=true` * macOS output: `build/output/main/dmg/` * Windows output: `build/output/main/exe/` * Linux output: `build/output/main/rpm/` If you want to run platform-specific tasks directly: ```bash ./gradlew packageDmg ./gradlew packageExe ./gradlew packageRpm ``` ## 🍎 macOS Packages ### Intel Chip: Monica-x64-1.1.4.dmg Download Link: https://pan.baidu.com/s/1ZS2e8krIh_kGUUEogMknrg?pwd=eyx7 ### Apple Silicon (M Series): Monica-arm64-1.1.4.dmg Download Link: https://pan.baidu.com/s/1JJwT_UNFrQa-tUsAYywqkA?pwd=mngu ## 🖥 Windows Package Monica-1.0.9.exe (latest version will be provided later, no Windows machine available now) Download Link: https://pan.baidu.com/s/1jL0bL17Omxtc2rqOBn9yWg?pwd=5dii ## 🐧 CentOS Package Coming soon # 📸 Screenshots ## ✨ New UI Preview Support for **English UI + Multiple Themes** English UI examples: ![](images/screenshot-en1.png) ![](images/screenshot-en2.png) Theme switching: ![](images/ui-theme-settings.png) Dark Theme: ![](images/ui-theme-dark.png) Purple Theme: ![](images/ui-theme-purple.png) ## 📷 Classic Features ![](images/screenshot.png) ![](images/screenshot-version.png) ![](images/4-2.png) ![](images/5-2.png) ![](images/7-2.png) More screenshots 👉 [Feature Overview](FUNCTION.md) Articles 👉 [Juejin Column](https://juejin.cn/column/7396157773312065574) # 📁 CV & AI Services ## ⚙️ CV Algorithms Code repo: https://github.com/fengzhizi715/MonicaImageProcess Currently, prebuilt algorithm libraries are available for macOS and Windows. Kotlin calls them via JNI. |Library Name | Version | Description | Notes | |---------------------------------------|-------|---------------------|---------------------------------| |libMonicaImageProcess.dylib |0.2.3 |Prebuilt for macOS | Built with CLion | |libopencv_world.4.10.0.dylib |– |OpenCV 4.10.0 prebuilt for macOS |Built with CMake | |MonicaImageProcess.dll | 0.2.1 |Prebuilt for Windows, depends on opencv_world481.dll| Built with Visual Studio 2022 | |opencv_world481.dll | – |OpenCV 4.8.1 prebuilt for Windows | Built with Visual Studio 2022 | ## ☁️ Deep Learning Services Monica communicates with deep learning inference services via HTTP. You need to set the `Algorithm Service URL` in **General Settings**. Source code & models 👉 https://github.com/fengzhizi715/MonicaImageProcessHttpServer > No online deployment provided. Feel free to build and run locally. # 💻 Roadmap * - [x] Multi-format import/export * - [x] Core image editing features * - [x] AI module integration * - [ ] Plugin system support * - [ ] More AI features (face retouching, background removal, style transfer, etc.) Upcoming TODO: * Unified error handling * Improved configuration management * Enhanced cropping tools * Face retouching * Image compression * Upgrade Kotlin Compose Desktop & third-party libraries # 🤝 Contributing Contributions of all kinds are welcome: new features, bug fixes, docs, or feedback. # 📄 License Apache License 2.0 # 📝 Changelog See [CHANGELOG](CHANGELOG.md) # 📬 Contact WeChat: fengzhizi715 Email: fengzhizi715@126.com # 📈 Star History [![Star History Chart](https://api.star-history.com/svg?repos=fengzhizi715/Monica&type=Date)](https://star-history.com/#fengzhizi715/Monica&Date) ================================================ FILE: README.md ================================================ **Monica** 是一款跨平台的桌面图像编辑软件。它不仅支持多种图像格式(包括相机 RAW),还集成了传统图像处理和基于深度学习的图像增强功能,提供可扩展、可二次开发的图像编辑体验。 # 🧪 技术栈 * **UI 框架**:Kotlin Compose Multiplatform (Desktop) * **图像处理**:OpenCV * **深度学习推理**:ONNX Runtime * **后端语言**:Kotlin / C++ * **构建工具**:Gradle / CMake # ✨ 功能列表 ## 📷 图像处理功能 * 支持导入:JPG、PNG、WebP、SVG、HDR、HEIC * 支持相机 RAW 文件导入:CR2、CR3 等 * 支持导出:JPG、PNG、WebP * 图像放大预览 * 局部模糊、马赛克处理 * 涂鸦、绘制形状、添加文字 * 图像取色 * 图像几何变换:翻转、旋转、缩放、错切 * 支持各种形状的裁剪 * 调整参数:对比度、色调、饱和度、亮度、色温、高光、阴影 * 50+ 可调节滤镜 * 多图合成 GIF * 快速验证 OpenCV 算法,支持简单算法的调参 ## 🤖 深度学习增强功能 * 人脸检测(人脸、性别、年龄) * 图像生成素描画 * 替换人脸 * 多种风格的漫画生成 # 📦 安装与运行 ## 从源码运行 使用 IntelliJ IDEA / IntelliJ IDEA CE ```bash git clone https://github.com/fengzhizi715/Monica.git cd Monica ./gradlew run ``` ## 打包 当前推荐直接使用统一打包命令: ```bash ./gradlew packageCurrentOsWithBundledWebRuntime ``` 说明: * 本地开发默认使用 `isProVersion=false` * 打包任务会自动切换到 `isProVersion=true` * macOS 产物默认输出到 `build/output/main/dmg/` * Windows 产物默认输出到 `build/output/main/exe/` * Linux 产物默认输出到 `build/output/main/rpm/` 如果需要直接调用平台任务,也可以使用: ```bash ./gradlew packageDmg ./gradlew packageExe ./gradlew packageRpm ``` ## 🍎 macOS 安装包 ### Intel 芯片: Monica-x64-1.1.4.dmg 链接: https://pan.baidu.com/s/1ZS2e8krIh_kGUUEogMknrg?pwd=eyx7 ### M 芯片: Monica-arm64-1.1.4.dmg 链接: https://pan.baidu.com/s/1JJwT_UNFrQa-tUsAYywqkA?pwd=mngu ## 🖥 Windows 安装包 Monica-1.0.9.exe (最近没有 windows 电脑,稍后提供最新的版本) 链接: https://pan.baidu.com/s/1jL0bL17Omxtc2rqOBn9yWg?pwd=5dii ## 🐧 CentOS 安装包 稍后提供 # 📸 项目截图 ## ✨ UI 新版预览图 支持 **英文版 UI + 多主题颜色切换** 英文版界面示例 ![](images/screenshot-en1.png) ![](images/screenshot-en2.png) 主题切换 ![](images/ui-theme-settings.png) 深色主题 ![](images/ui-theme-dark.png) 紫色主题 ![](images/ui-theme-purple.png) ## 📷 经典功能界面 ![](images/screenshot.png) ![](images/screenshot-version.png) ![](images/4-2.png) ![](images/5-2.png) ![](images/7-2.png) 更多截图 👉 [详细功能介绍](FUNCTION.md) 专栏文章 👉 [掘金专栏](https://juejin.cn/column/7396157773312065574) # 📁 CV 算法 && 深度学习的服务 ## ⚙️ CV 算法 CV 算法的地址: https://github.com/fengzhizi715/MonicaImageProcess 目前在 macOS、Windows 环境下编译好了相关的算法库,Kotlin 通过 jni 来调用该算法库。 | 库名 | 版本号 | 描述 | 备注 | |---------------------------------------|-------|------------------------------------------------------|---------------------------------| | libMonicaImageProcess.dylib | 0.2.3 | macOS 下编译好的算法库 | 使用 CLion 编译 | | libopencv_world.4.10.0.dylib | | macOS 下基于 OpenCV 4.10.0 源码编译的 OpenCV 库 | 使用 cmake 编译 | | MonicaImageProcess.dll | 0.2.1 | Windows 下编译好的算法库需要依赖 opencv_world481.dll | 使用 Visual Studio 2022 编译 | | opencv_world481.dll | | Windows 下基于 OpenCV 4.8.1 源码编译的 OpenCV 库 | 使用 Visual Studio 2022 编译 | ## ☁️ 深度学习的服务 Monica 通过 HTTP 调用深度学习推理服务。需在 **通用设置** 中配置 `算法服务 URL`。 源码与模型 👉 https://github.com/fengzhizi715/MonicaImageProcessHttpServer > 未部署线上服务,感兴趣可自行编译和部署 # 💻 项目计划: * - [x] 多格式导入导出支持 * - [x] 图像基础编辑功能 * - [x] 深度学习模块集成 * - [ ] 支持插件机制 * - [ ] 添加更多 AI 功能(如人脸美颜、去背景、风格化等) 近期的 TODO : * 优化滤镜模块,使用 LLM 实现自然语言使用滤镜 * 完善配置管理 * 优化图像裁剪的功能 * 增加人脸美颜的功能 * 增加插件机制 * 升级 Kotlin Compose desktop、第三方库的版本 # 🤝 贡献方式 欢迎任何形式的贡献,包括但不限于功能开发、Bug 修复、文档完善和使用反馈。 # 📄 开源协议 本项目基于 Apache License 2.0 开源。 # 📝 更新日志 请查看 [CHANGELOG](CHANGELOG.md) 文件 # 📬 联系方式: wechat:fengzhizi715 Email:fengzhizi715@126.com # 📈 Star History [![Star History Chart](https://api.star-history.com/svg?repos=fengzhizi715/Monica&type=Date)](https://star-history.com/#fengzhizi715/Monica&Date) ================================================ FILE: build.gradle.kts ================================================ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import java.io.File import java.io.FileOutputStream import java.net.URL import java.security.MessageDigest plugins { kotlin("multiplatform") id("org.jetbrains.compose") id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" } group = "cn.netdiscovery.monica" version = "${rootProject.extra["app.version"]}" val mOutputDir = project.buildDir.resolve("output") repositories { google() mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") maven( "https://jitpack.io" ) } val osName = System.getProperty("os.name") val targetOs = when { osName == "Mac OS X" -> "macos" osName.startsWith("Win") -> "windows" osName.startsWith("Linux") -> "linux" else -> error("Unsupported OS: $osName") } val osArch = System.getProperty("os.arch") var targetArch = when (osArch) { "x86_64", "amd64" -> "x64" "aarch64" -> "arm64" else -> error("Unsupported arch: $osArch") } val skikoVersion = "0.8.4" val target = "${targetOs}-${targetArch}" val resourcesRootDir = project.layout.projectDirectory.dir("resources").asFile val bundledNodeVersion = providers.gradleProperty("bundledNodeVersion").orElse("20.18.0") val stagedWebRuntimeDir = layout.buildDirectory.dir("generated/web-screenshot-runtime/$target") val packagedWebRuntimeDir = layout.buildDirectory.dir("generated/web-screenshot-payload/$target") val packagedWebRuntimeZip = layout.buildDirectory.file("generated/web-screenshot-payload/$target/runtime.zip") val packagedWebRuntimeResourceDir = File(resourcesRootDir, "common/web-screenshot-runtime/$target") val packagedWebRuntimeResourceZip = File(packagedWebRuntimeResourceDir, "runtime.zip") fun getBundledNodeDistPlatform(): String = when (targetOs) { "macos" -> if (targetArch == "arm64") "darwin-arm64" else "darwin-x64" "windows" -> if (targetArch == "arm64") "win-arm64" else "win-x64" "linux" -> if (targetArch == "arm64") "linux-arm64" else "linux-x64" else -> error("Unsupported target OS for bundled Node.js: $targetOs") } fun getBundledNodeArchiveExtension(): String = if (targetOs == "windows") "zip" else "tar.gz" fun getBundledNodeExtractedDirName(version: String): String = "node-v$version-${getBundledNodeDistPlatform()}" fun sha256(file: File): String { val digest = MessageDigest.getInstance("SHA-256") file.inputStream().use { input -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) while (true) { val read = input.read(buffer) if (read <= 0) break digest.update(buffer, 0, read) } } return digest.digest().joinToString("") { "%02x".format(it) } } fun findWebScreenshotRuntimeRoot(baseDir: File): File? { val candidates = listOf( baseDir, File(baseDir, "common") ) return candidates.firstOrNull { candidate -> File(candidate, "web-screenshot.js").exists() } } fun findBundledNodeExecutable(baseDir: File): File? { val candidates = if (targetOs == "windows") { listOf( File(baseDir, "$target/node/node.exe"), File(baseDir, "$target/node.exe") ) } else { listOf( File(baseDir, "$target/node/bin/node"), File(baseDir, "$target/node/node"), File(baseDir, "$target/bin/node") ) } return candidates.firstOrNull { it.exists() } } fun findBundledNodeExecutableInRuntime(runtimeRoot: File): File? { val candidates = if (targetOs == "windows") { listOf( File(runtimeRoot, "node/node.exe"), File(runtimeRoot, "node.exe") ) } else { listOf( File(runtimeRoot, "node/bin/node"), File(runtimeRoot, "node/node"), File(runtimeRoot, "bin/node") ) } return candidates.firstOrNull { it.exists() } } fun findPlaywrightBrowsersInRuntime(runtimeRoot: File): File? { val candidates = listOf( File(runtimeRoot, "node_modules/playwright-core/.local-browsers"), File(runtimeRoot, "ms-playwright") ) return candidates.firstOrNull { it.exists() } } fun hasInstalledPlaywrightBrowsersInRuntime(runtimeRoot: File): Boolean { val browsersDir = findPlaywrightBrowsersInRuntime(runtimeRoot) ?: return false val entries = browsersDir.listFiles().orEmpty().filter { !it.name.startsWith(".") } val hasChromium = entries.any { it.name.startsWith("chromium-") } val hasHeadlessShell = entries.any { it.name.startsWith("chromium_headless_shell-") } val hasFfmpeg = entries.any { it.name.startsWith("ffmpeg-") } return hasChromium && hasHeadlessShell && hasFfmpeg } fun getNpmCliScript(nodeDir: File): File { val script = File(nodeDir, "lib/node_modules/npm/bin/npm-cli.js") if (!script.exists()) { throw GradleException("Bundled npm CLI not found at ${script.absolutePath}") } return script } fun getNpxCliScript(nodeDir: File): File { val directScript = File(nodeDir, "lib/node_modules/npm/bin/npx-cli.js") if (directScript.exists()) { return directScript } val fallbackScript = File(nodeDir, "lib/node_modules/npm/bin/npm-cli.js") if (fallbackScript.exists()) { return fallbackScript } throw GradleException("Bundled npx/npm CLI not found under ${nodeDir.absolutePath}") } fun getBundledNodeCommand(nodeDir: File): File { val executable = if (targetOs == "windows") { File(nodeDir, "node.exe") } else { File(nodeDir, "bin/node") } if (!executable.exists()) { throw GradleException("Bundled Node.js executable not found at ${executable.absolutePath}") } return executable } kotlin { jvm { withJava() } sourceSets { val jvmMain by getting { dependencies { implementation(compose.desktop.currentOs) implementation(project(":domain")) implementation(project(":config")) implementation(project(":imageprocess")) implementation(project(":opencv")) implementation(project(":i18n")) implementation ("org.jetbrains.kotlin:kotlin-reflect") // skiko implementation("org.jetbrains.skiko:skiko-awt-runtime-$target:$skikoVersion") // 缓存 implementation("com.github.fengzhizi715.RxCache:core:${rootProject.extra["rxcache"]}") implementation("com.github.fengzhizi715.RxCache:okio:${rootProject.extra["rxcache"]}") implementation("com.github.fengzhizi715.RxCache:extension:${rootProject.extra["rxcache"]}") // di implementation("io.insert-koin:koin-compose:${rootProject.extra["koin.compose"]}") // color math implementation("com.github.ajalt.colormath:colormath-ext-jetpack-compose:${rootProject.extra["colormath"]}") // coroutines utils implementation ("com.github.fengzhizi715.Kotlin-Coroutines-Utils:common:${rootProject.extra["coroutines.utils"]}") // 为 Desktop/Swing 提供 Dispatchers.Main(绑定到 EDT) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:${rootProject.extra["kotlinx.coroutines.core.version"]}") // log config implementation("ch.qos.logback:logback-classic:${rootProject.extra["logback"]}") implementation("ch.qos.logback:logback-core:${rootProject.extra["logback"]}") implementation("ch.qos.logback:logback-access:${rootProject.extra["logback"]}") // okhttp-extension implementation("com.github.fengzhizi715.okhttp-extension:core:1.3.2") implementation("com.github.fengzhizi715.okhttp-logging-interceptor:core:v1.1.4") implementation ("com.squareup.okhttp3:okhttp:4.10.0") implementation ("com.google.code.gson:gson:2.10.1") implementation ("org.json:json:20240303") // generate gif implementation ("com.madgag:animated-gif-lib:1.4") // sqlite implementation("org.xerial:sqlite-jdbc:3.50.3.0") } } val jvmTest by getting { dependencies { implementation(kotlin("test")) } } } } val verifyBundledWebScreenshotRuntime by tasks.registering { group = "verification" description = "Verify offline web screenshot payload resources for desktop packaging." doLast { val runtimeRoot = findWebScreenshotRuntimeRoot(resourcesRootDir) ?: throw GradleException( "Missing web screenshot runtime root. Expected web-screenshot.js under " + "${resourcesRootDir.absolutePath} or ${File(resourcesRootDir, "common").absolutePath}." ) val missing = mutableListOf() if (!File(runtimeRoot, "package.json").exists()) { missing += "${runtimeRoot.absolutePath}/package.json" } val payloadZip = packagedWebRuntimeResourceZip if (!payloadZip.exists()) { missing += payloadZip.absolutePath } if (missing.isNotEmpty()) { throw GradleException( buildString { appendLine("Bundled web screenshot runtime is incomplete for target '$target'.") appendLine("Missing resources:") missing.forEach { appendLine("- $it") } appendLine("Suggested setup:") appendLine("1. Prepare the offline runtime payload") appendLine(" ./gradlew prepareBundledWebScreenshotRuntime") appendLine("2. Re-run packaging") } ) } } } tasks.matching { it.name in setOf( "createDistributable", "packageDistributionForCurrentOS", "packageDmg", "packageMsi", "packageExe", "packageRpm" ) }.configureEach { dependsOn(verifyBundledWebScreenshotRuntime) } val downloadBundledNode by tasks.registering { group = "distribution" description = "Download and unpack bundled Node.js into the staged offline runtime payload." val version = bundledNodeVersion.get() val distPlatform = getBundledNodeDistPlatform() val archiveExtension = getBundledNodeArchiveExtension() val archiveFile = layout.buildDirectory.file("tmp/bundled-node/node-v$version-$distPlatform.$archiveExtension") val extractDir = layout.buildDirectory.dir("tmp/bundled-node/extracted/$target") val targetNodeDir = stagedWebRuntimeDir.map { it.dir("node").asFile } doLast { val versionValue = bundledNodeVersion.get() val runtimeRoot = stagedWebRuntimeDir.get().asFile val nodeRoot = targetNodeDir.get() val versionMarker = File(nodeRoot, ".bundled-node-version") val existingNode = findBundledNodeExecutableInRuntime(runtimeRoot) if (existingNode != null && versionMarker.exists() && versionMarker.readText().trim() == versionValue) { logger.lifecycle("Bundled Node.js already prepared at ${nodeRoot.absolutePath}, skipping download.") return@doLast } val downloadUrl = "https://nodejs.org/dist/v$versionValue/${getBundledNodeExtractedDirName(versionValue)}.$archiveExtension" val archive = archiveFile.get().asFile val extractedRoot = extractDir.get().asFile archive.parentFile.mkdirs() extractedRoot.mkdirs() logger.lifecycle("Downloading bundled Node.js from $downloadUrl") URL(downloadUrl).openStream().use { input -> FileOutputStream(archive).use { output -> input.copyTo(output) } } project.delete(extractedRoot) extractedRoot.mkdirs() copy { from( if (archiveExtension == "zip") { zipTree(archive) } else { tarTree(resources.gzip(archive)) } ) into(extractedRoot) } val extractedNodeDir = File(extractedRoot, getBundledNodeExtractedDirName(versionValue)) if (!extractedNodeDir.exists()) { throw GradleException("Downloaded Node.js archive did not contain ${extractedNodeDir.absolutePath}") } project.delete(nodeRoot) nodeRoot.parentFile.mkdirs() copy { from(extractedNodeDir) into(nodeRoot) } if (targetOs != "windows") { listOf( File(nodeRoot, "bin/node"), File(nodeRoot, "node") ).filter { it.exists() }.forEach { file -> file.setExecutable(true) } } versionMarker.writeText("$versionValue\n") logger.lifecycle("Bundled Node.js prepared at ${nodeRoot.absolutePath}") } } val installBundledPlaywrightRuntime by tasks.registering { group = "distribution" description = "Stage the offline Playwright runtime that will be extracted on first use." dependsOn(downloadBundledNode) val sourceRuntimeRoot = findWebScreenshotRuntimeRoot(resourcesRootDir) ?: resourcesRootDir val runtimeRoot = stagedWebRuntimeDir.get().asFile val lockFile = File(sourceRuntimeRoot, "package-lock.json") val packageFile = File(sourceRuntimeRoot, "package.json") val runtimeStampFile = File(runtimeRoot, ".playwright-runtime-stamp") inputs.file(packageFile) doLast { runtimeRoot.mkdirs() copy { from(File(sourceRuntimeRoot, "web-screenshot.js")) from(packageFile) if (lockFile.exists()) { from(lockFile) } into(runtimeRoot) } val nodeDir = findBundledNodeExecutableInRuntime(runtimeRoot)?.parentFile?.let { parent -> if (targetOs == "windows") parent else parent.parentFile } ?: throw GradleException("Bundled Node.js is missing. Run downloadBundledNode first.") val lockHash = if (lockFile.exists()) sha256(lockFile) else sha256(packageFile) val expectedStamp = buildString { append("node=") append(bundledNodeVersion.get()) append('\n') append("lock=") append(lockHash) append('\n') append("target=") append(target) append('\n') } if (runtimeStampFile.exists() && runtimeStampFile.readText() == expectedStamp && File(runtimeRoot, "node_modules/playwright/package.json").exists() && File(runtimeRoot, "node_modules/playwright-core/package.json").exists() && hasInstalledPlaywrightBrowsersInRuntime(runtimeRoot) ) { logger.lifecycle("Bundled Playwright runtime already installed in ${runtimeRoot.absolutePath}, skipping npm install.") return@doLast } val nodeExecutable = getBundledNodeCommand(nodeDir) val npmCli = getNpmCliScript(nodeDir) val npxCli = getNpxCliScript(nodeDir) logger.lifecycle("Installing web screenshot npm dependencies in ${runtimeRoot.absolutePath}") exec { workingDir = runtimeRoot executable = nodeExecutable.absolutePath args = listOf(npmCli.absolutePath, "ci") environment("PLAYWRIGHT_BROWSERS_PATH", "0") } logger.lifecycle("Installing bundled Chromium in ${runtimeRoot.absolutePath}") val playwrightInstallArgs = if (npxCli.name == "npm-cli.js") { listOf(npxCli.absolutePath, "exec", "playwright", "install", "chromium") } else { listOf(npxCli.absolutePath, "playwright", "install", "chromium") } exec { workingDir = runtimeRoot executable = nodeExecutable.absolutePath args = playwrightInstallArgs environment("PLAYWRIGHT_BROWSERS_PATH", "0") } runtimeStampFile.writeText(expectedStamp) } } val packageBundledWebScreenshotRuntime by tasks.registering(Zip::class) { group = "distribution" description = "Create the offline runtime zip that the app will extract on first screenshot use." dependsOn(installBundledPlaywrightRuntime) from(stagedWebRuntimeDir) destinationDirectory.set(packagedWebRuntimeDir) archiveFileName.set("runtime.zip") } val cleanupLegacyWebScreenshotPackagingResources by tasks.registering { group = "distribution" description = "Remove legacy bundled web screenshot executables from source resources so packaging stays close to main." doNotTrackState("This task cleans legacy packaging artifacts from source resources in place.") doLast { listOf( File(resourcesRootDir, "node_modules"), File(resourcesRootDir, "common/node_modules"), File(resourcesRootDir, "ms-playwright"), File(resourcesRootDir, "common/ms-playwright"), File(resourcesRootDir, ".playwright-runtime-stamp"), File(resourcesRootDir, "macos-arm64/node"), File(resourcesRootDir, "macos-x64/node"), File(resourcesRootDir, "linux-arm64/node"), File(resourcesRootDir, "linux-x64/node"), File(resourcesRootDir, "windows/node") ).forEach { path -> if (path.exists()) { project.delete(path) } } } } val preparePackagedResources by tasks.registering { group = "distribution" description = "Write the offline web screenshot payload into standard resources layout." dependsOn(packageBundledWebScreenshotRuntime, cleanupLegacyWebScreenshotPackagingResources) doNotTrackState("This task prepares standard source resources in place for Compose Desktop packaging.") doLast { packagedWebRuntimeResourceDir.mkdirs() copy { from(packagedWebRuntimeZip) into(packagedWebRuntimeResourceDir) } } } val prepareBundledWebScreenshotRuntime by tasks.registering { group = "distribution" description = "Prepare the offline web screenshot payload for packaging." dependsOn(preparePackagedResources) } val cleanNativeDistributionOutputs by tasks.registering { group = "distribution" description = "Remove stale native distribution outputs so old app resources are not reused across packaging runs." doNotTrackState("This task deletes generated packaging outputs before creating new native bundles.") doLast { listOf( File(mOutputDir, "main/app"), File(mOutputDir, "main/dmg"), File(mOutputDir, "main/msi"), File(mOutputDir, "main/exe"), File(mOutputDir, "main/rpm") ).forEach { dir -> if (dir.exists()) { project.delete(dir) } } } } verifyBundledWebScreenshotRuntime.configure { dependsOn(prepareBundledWebScreenshotRuntime) } val packageCurrentOsWithBundledWebRuntime by tasks.registering { group = "distribution" description = "Prepare bundled web screenshot runtime and package for the current OS." val packageTaskName = when (targetOs) { "macos" -> "packageDmg" "windows" -> "packageExe" "linux" -> "packageRpm" else -> error("Unsupported target OS: $targetOs") } dependsOn(packageTaskName) } val currentOsPackageTaskName = when (targetOs) { "macos" -> "packageDmg" "windows" -> "packageExe" "linux" -> "packageRpm" else -> error("Unsupported target OS: $targetOs") } tasks.matching { it.name == currentOsPackageTaskName }.configureEach { dependsOn(cleanNativeDistributionOutputs, prepareBundledWebScreenshotRuntime) } java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } compose.desktop { application { mainClass = "MainKt" buildTypes.release.proguard { configurationFiles.from(project.file("compose-desktop.pro")) } nativeDistributions { outputBaseDir.set(mOutputDir) //build/output targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Exe, TargetFormat.Rpm) appResourcesRootDir.set(project.layout.projectDirectory.dir("resources")) packageName = "Monica-$targetArch" packageVersion = "${rootProject.extra["app.version"]}" description = "Monica is a cross-platform image editor" copyright = "© 2024 Tony Shen. All rights reserved." jvmArgs += listOf("-Xms4G","-Xmx4G") jvmArgs += listOf("-Dlogback.debug=true") includeAllModules = true //包含所有模块 macOS { bundleID = "cn.netdiscovery.monica" dockName = "monica" } windows { console = false // 为应用程序添加一个控制台启动器 shortcut = true // 桌面快捷方式 dirChooser = true // 允许在安装过程中自定义安装路径 perUserInstall = false //允许在每个用户的基础上安装应用程序 menuGroup = "start-menu-group" upgradeUuid = "b329caf3-6681-49b9-98d0-adb34d32e130" iconFile.set(project.file("src/jvmMain/resources/images/launcher.ico")) } } } } ================================================ FILE: compose-desktop.pro ================================================ -dontwarn org.slf4j.** -dontwarn ch.qos.logback.** -dontwarn com.google.gson.** -dontwarn org.apache.** -dontwarn com.safframework.rxcache.** -keep class cn.netdiscovery.monica.** {*;} -keep interface ccn.netdiscovery.monica.** {*;} -keep enum cn.netdiscovery.monica.** {*;} ================================================ FILE: config/build.gradle.kts ================================================ plugins { kotlin("jvm") id("com.github.gmazzo.buildconfig") version "5.4.0" } repositories { mavenCentral() maven { url = uri("https://jitpack.io") } } val requestedTaskNames = gradle.startParameter.taskNames val isPackagingBuild = requestedTaskNames.any { taskName -> val normalized = taskName.substringAfterLast(':') normalized in setOf( "packageCurrentOsWithBundledWebRuntime", "packageDmg", "packageMsi", "packageExe", "packageRpm", "packageDistributionForCurrentOS", "createDistributable" ) } val isProVersion = providers.gradleProperty("isProVersion") .map(String::toBoolean) .orElse(isPackagingBuild) .get() buildConfig { useKotlinOutput { topLevelConstants = true } useKotlinOutput { internalVisibility = false } // adds `internal` modifier to all declarations buildConfigField("APP_NAME", project.name) buildConfigField("APP_VERSION", "${rootProject.extra["app.version"]}") buildConfigField("KOTLIN_VERSION", "${rootProject.extra["kotlin.version"]}") buildConfigField("COMPOSE_VERSION", "${rootProject.extra["compose.version"]}") buildConfigField("IS_PRO_VERSION", isProVersion) buildConfigField("BUILD_TIME", System.currentTimeMillis()) } dependencies { testImplementation(kotlin("test")) implementation ("org.jetbrains.kotlin:kotlin-stdlib") // Logging implementation("ch.qos.logback:logback-classic:${rootProject.extra["logback"]}") implementation("ch.qos.logback:logback-core:${rootProject.extra["logback"]}") // Gson for JSON serialization implementation("com.google.code.gson:gson:2.10.1") // Domain module (for GeneralSettings) implementation(project(":domain")) // RxCache (for type definitions, instance will be provided by main project) implementation("com.github.fengzhizi715.RxCache:core:${rootProject.extra["rxcache"]}") implementation("com.github.fengzhizi715.RxCache:extension:${rootProject.extra["rxcache"]}") } tasks.test { useJUnitPlatform() } kotlin { jvmToolchain(17) } ================================================ FILE: config/src/main/kotlin/cn/netdiscovery/monica/config/Constants.kt ================================================ package cn.netdiscovery.monica.config import java.text.SimpleDateFormat /** * * @FileName: * cn.netdiscovery.monica.config.Constants * @author: Tony Shen * @date: 2024/5/7 10:55 * @version: V1.0 <描述当前版本功能> */ val appVersion by lazy { Monica.config.BuildConfig.APP_VERSION } val kotlinVersion by lazy { Monica.config.BuildConfig.KOTLIN_VERSION } val composeVersion by lazy { Monica.config.BuildConfig.COMPOSE_VERSION } val isProVersion by lazy { Monica.config.BuildConfig.IS_PRO_VERSION } val buildTime:String by lazy { val time = Monica.config.BuildConfig.BUILD_TIME val dateformat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") dateformat.format(time) } const val KEY_CROP_FIRST = "key_crop_first" const val KEY_CROP_SECOND = "key_crop_second" const val KEY_CROP = "key_crop" const val KEY_GENERAL_SETTINGS = "key_general_settings" const val STATUS_HTTP_SERVER_OK = 1 const val STATUS_HTTP_SERVER_FAILED = 0 const val MODULE_COLOR = "module_color" const val MODULE_OPENCV = "module_opencv" ================================================ FILE: config/src/main/kotlin/cn/netdiscovery/monica/config/SystemConstants.kt ================================================ package cn.netdiscovery.monica.config /** * * @FileName: * cn.netdiscovery.monica.utils.SystemUtils * @author: Tony Shen * @date: 2024/7/6 14:36 * @version: V1.0 <描述当前版本功能> */ val os: String = System.getProperty("os.name") val arch: String = System.getProperty("os.arch") val osVersion: String = System.getProperty("os.version") val javaVersion: String = System.getProperty("java.version") val javaVendor: String = System.getProperty("java.vendor") val workDirectory: String = System.getProperty("user.dir") val userHome: String = System.getProperty("user.home") val isMac by lazy { os.contains("Mac") } val isWindows by lazy { os.startsWith("Win") } val isLinux by lazy { os.contains("nux") || os.contains("nix") } ================================================ FILE: config/src/main/kotlin/cn/netdiscovery/monica/config/category/ConfigCategory.kt ================================================ package cn.netdiscovery.monica.config.category /** * 配置分类枚举 * * 用于区分不同类型的配置,便于统一管理和验证。 * * @author: Tony Shen * @date: 2025-12-12 */ enum class ConfigCategory { /** * 应用设置(用户可修改的设置,如 GeneralSettings) */ APP_SETTINGS, /** * UI 配置(UI 相关的配置,如滤镜参数元数据) */ UI_CONFIG, /** * 业务配置(业务逻辑相关的配置,如 API 密钥、算法 URL) */ BUSINESS_CONFIG, /** * 用户偏好(用户个人偏好设置,如语言、主题) */ USER_PREFERENCE, /** * 临时配置(临时存储的配置,如裁剪状态) */ TEMPORARY } ================================================ FILE: config/src/main/kotlin/cn/netdiscovery/monica/config/category/ConfigCategoryManager.kt ================================================ package cn.netdiscovery.monica.config.category import cn.netdiscovery.monica.config.storage.ConfigManager import cn.netdiscovery.monica.config.storage.ConfigStorage import cn.netdiscovery.monica.config.storage.ConfigType import org.slf4j.Logger import org.slf4j.LoggerFactory /** * 配置分类管理器 * * 根据配置分类选择合适的存储和验证策略。 * * @author: Tony Shen * @date: 2025-12-12 */ object ConfigCategoryManager { private val logger: Logger = LoggerFactory.getLogger(ConfigCategoryManager::class.java) /** * 配置元信息 */ data class ConfigMetadata( val key: String, val category: ConfigCategory, val storageType: ConfigType, val validator: ConfigValidator? = null, val defaultValue: T ) /** * 配置注册表 */ private val configRegistry = mutableMapOf>() /** * 注册配置 */ fun register(metadata: ConfigMetadata) { configRegistry[metadata.key] = metadata logger.debug("Registered config: key=${metadata.key}, category=${metadata.category}") } /** * 获取配置元信息 */ @Suppress("UNCHECKED_CAST") fun getMetadata(key: String): ConfigMetadata? { return configRegistry[key] as? ConfigMetadata } /** * 保存配置(带验证) */ fun save(key: String, value: T): ValidationResult { val metadata = getMetadata(key) // 验证配置值 metadata?.validator?.let { validator -> val error = validator.validate(value) if (error != null) { logger.warn("Config validation failed: key=$key, error=$error") return ValidationResult.failure(error) } } // 保存配置 try { val storageType = metadata?.storageType ?: ConfigType.DEFAULT ConfigManager.save(key, value, storageType) logger.debug("Config saved: key=$key, category=${metadata?.category}") return ValidationResult.success() } catch (e: Exception) { logger.error("Failed to save config: key=$key", e) return ValidationResult.failure("Failed to save config: ${e.message}") } } /** * 加载配置(带默认值) */ @Suppress("UNCHECKED_CAST") fun load(key: String): T? { val metadata = getMetadata(key) val storageType = metadata?.storageType ?: ConfigType.DEFAULT val defaultValue = metadata?.defaultValue return if (defaultValue != null) { ConfigManager.load(key, defaultValue, storageType) as T } else { logger.warn("No default value for config: key=$key") null } } /** * 加载配置(带自定义默认值) */ fun load(key: String, default: T): T { val metadata = getMetadata(key) val storageType = metadata?.storageType ?: ConfigType.DEFAULT return ConfigManager.load(key, default, storageType) } /** * 验证配置值(不保存) */ fun validate(key: String, value: T): ValidationResult { val metadata = getMetadata(key) val validator = metadata?.validator ?: return ValidationResult.success() val error = validator.validate(value) return if (error != null) { ValidationResult.failure(error) } else { ValidationResult.success() } } /** * 获取配置的存储类型 */ fun getStorageType(key: String): ConfigType { return getMetadata(key)?.storageType ?: ConfigType.DEFAULT } /** * 获取配置的分类 */ fun getCategory(key: String): ConfigCategory? { return getMetadata(key)?.category } /** * 获取所有已注册的配置键 */ fun getAllKeys(): List { return configRegistry.keys.toList() } /** * 获取指定分类的所有配置键 */ fun getKeysByCategory(category: ConfigCategory): List { return configRegistry.filter { it.value.category == category }.keys.toList() } /** * 清除指定分类的所有配置 */ fun clearCategory(category: ConfigCategory) { val keys = getKeysByCategory(category) keys.forEach { key -> val metadata = getMetadata(key) val storageType = metadata?.storageType ?: ConfigType.DEFAULT ConfigManager.remove(key, storageType) } logger.info("Cleared all configs in category: $category") } } ================================================ FILE: config/src/main/kotlin/cn/netdiscovery/monica/config/category/ConfigDefinitions.kt ================================================ package cn.netdiscovery.monica.config.category import cn.netdiscovery.monica.config.KEY_GENERAL_SETTINGS import cn.netdiscovery.monica.config.storage.ConfigType import cn.netdiscovery.monica.domain.GeneralSettings /** * 配置定义 * * 集中定义所有配置的元信息,包括分类、存储类型、验证规则和默认值。 * * @author: Tony Shen * @date: 2025-12-12 */ object ConfigDefinitions { /** * 初始化所有配置定义 */ fun initialize() { registerAppSettings() registerUserPreferences() registerTemporaryConfigs() } /** * 注册应用设置 */ private fun registerAppSettings() { // GeneralSettings ConfigCategoryManager.register( ConfigCategoryManager.ConfigMetadata( key = KEY_GENERAL_SETTINGS, category = ConfigCategory.APP_SETTINGS, storageType = ConfigType.RX_CACHE, defaultValue = GeneralSettings( outputBoxR = 255, outputBoxG = 255, outputBoxB = 255, size = 512, maxHistorySize = 50, deepSeekApiKey = "", geminiApiKey = "", algorithmUrl = "", themeId = "LIGHT" ) ) ) } /** * 注册用户偏好设置 */ private fun registerUserPreferences() { // 语言设置 ConfigCategoryManager.register( ConfigCategoryManager.ConfigMetadata( key = "selected_language", category = ConfigCategory.USER_PREFERENCE, storageType = ConfigType.PREFERENCES, defaultValue = "zh" ) ) } /** * 注册临时配置 */ private fun registerTemporaryConfigs() { // 裁剪相关临时配置已在 Constants.kt 中定义 // 这里可以添加其他临时配置的定义 } } ================================================ FILE: config/src/main/kotlin/cn/netdiscovery/monica/config/category/ConfigValidator.kt ================================================ package cn.netdiscovery.monica.config.category import org.slf4j.Logger import org.slf4j.LoggerFactory /** * 配置验证器接口 * * 用于验证配置值的有效性。 * * @author: Tony Shen * @date: 2025-12-12 */ interface ConfigValidator { /** * 验证配置值 * * @param value 配置值 * @return 验证结果,如果有效返回 null,否则返回错误信息 */ fun validate(value: T): String? } /** * 配置验证结果 */ data class ValidationResult( val isValid: Boolean, val errorMessage: String? = null ) { companion object { fun success() = ValidationResult(true) fun failure(message: String) = ValidationResult(false, message) } } /** * 通用配置验证器 */ object CommonValidators { private val logger: Logger = LoggerFactory.getLogger(CommonValidators::class.java) /** * 字符串非空验证器 */ fun nonEmptyString(): ConfigValidator = object : ConfigValidator { override fun validate(value: String): String? { return if (value.isBlank()) { "Value cannot be empty" } else { null } } } /** * 字符串长度验证器 */ fun stringLength(min: Int, max: Int): ConfigValidator = object : ConfigValidator { override fun validate(value: String): String? { return when { value.length < min -> "Value length must be at least $min" value.length > max -> "Value length must be at most $max" else -> null } } } /** * 数值范围验证器 */ fun numberRange(min: T, max: T): ConfigValidator = object : ConfigValidator { override fun validate(value: T): String? { val doubleValue = value.toDouble() val minValue = min.toDouble() val maxValue = max.toDouble() return when { doubleValue < minValue -> "Value must be at least $min" doubleValue > maxValue -> "Value must be at most $max" else -> null } } } /** * 整数范围验证器 */ fun intRange(min: Int, max: Int): ConfigValidator = object : ConfigValidator { override fun validate(value: Int): String? { return when { value < min -> "Value must be at least $min" value > max -> "Value must be at most $max" else -> null } } } /** * URL 验证器 */ fun url(): ConfigValidator = object : ConfigValidator { override fun validate(value: String): String? { return try { java.net.URL(value) null } catch (e: Exception) { "Invalid URL format: $value" } } } /** * 组合验证器(多个验证器同时生效) */ fun combine(vararg validators: ConfigValidator): ConfigValidator = object : ConfigValidator { override fun validate(value: T): String? { validators.forEach { validator -> validator.validate(value)?.let { return it } } return null } } /** * 可选验证器(值为 null 时跳过验证) */ fun optional(validator: ConfigValidator): ConfigValidator = object : ConfigValidator { override fun validate(value: T?): String? { return if (value == null) { null } else { validator.validate(value) } } } } ================================================ FILE: config/src/main/kotlin/cn/netdiscovery/monica/config/storage/ConfigManager.kt ================================================ package cn.netdiscovery.monica.config.storage import com.safframework.rxcache.RxCache import org.slf4j.Logger import org.slf4j.LoggerFactory /** * 统一配置管理器 * * 管理不同类型的配置存储,提供统一的配置访问接口。 * 支持多种存储后端: * - RxCache: 用于复杂对象(如 GeneralSettings) * - Preferences: 用于简单键值对(如语言设置) * - File: 用于 JSON 配置文件(如滤镜参数元数据) * * 注意:需要先调用 initialize() 方法初始化,传入 RxCache 实例。 * * @author: Tony Shen * @date: 2025-12-12 */ object ConfigManager { private val logger: Logger = LoggerFactory.getLogger(ConfigManager::class.java) private var _rxCacheStorage: ConfigStorage? = null /** * RxCache 存储(用于复杂对象) */ val rxCacheStorage: ConfigStorage get() = _rxCacheStorage ?: throw IllegalStateException("ConfigManager not initialized. Call initialize() first.") /** * Preferences 存储(用于简单键值对) */ val preferencesStorage: ConfigStorage = PreferencesConfigStorage() /** * 默认存储(优先使用 RxCache) */ val defaultStorage: ConfigStorage get() = rxCacheStorage /** * 初始化 ConfigManager * * @param rxCache RxCache 实例 */ fun initialize(rxCache: RxCache) { _rxCacheStorage = RxCacheConfigStorage(rxCache) logger.info("ConfigManager initialized") } /** * 根据配置类型选择合适的存储 * * @param configType 配置类型 * @return 对应的存储实例 */ fun getStorage(configType: ConfigType = ConfigType.DEFAULT): ConfigStorage { return when (configType) { ConfigType.RX_CACHE -> rxCacheStorage ConfigType.PREFERENCES -> preferencesStorage ConfigType.DEFAULT -> defaultStorage } } /** * 保存配置(使用默认存储) */ fun save(key: String, value: T, configType: ConfigType = ConfigType.DEFAULT) { try { getStorage(configType).save(key, value) logger.debug("Config saved: key=$key, type=$configType") } catch (e: Exception) { logger.error("Failed to save config: key=$key, type=$configType", e) throw e } } /** * 加载配置(使用默认存储) */ fun load(key: String, default: T, configType: ConfigType = ConfigType.DEFAULT): T { return try { val value = getStorage(configType).load(key, default) logger.debug("Config loaded: key=$key, type=$configType, found=${value != default}") value } catch (e: Exception) { logger.warn("Failed to load config: key=$key, type=$configType, using default", e) default } } /** * 检查配置是否存在 */ fun exists(key: String, configType: ConfigType = ConfigType.DEFAULT): Boolean { return getStorage(configType).exists(key) } /** * 删除配置 */ fun remove(key: String, configType: ConfigType = ConfigType.DEFAULT) { try { getStorage(configType).remove(key) logger.debug("Config removed: key=$key, type=$configType") } catch (e: Exception) { logger.error("Failed to remove config: key=$key, type=$configType", e) throw e } } /** * 清空指定类型的配置 */ fun clear(configType: ConfigType = ConfigType.DEFAULT) { try { getStorage(configType).clear() logger.info("Config cleared: type=$configType") } catch (e: Exception) { logger.error("Failed to clear config: type=$configType", e) throw e } } /** * 获取所有配置键 */ fun getAllKeys(configType: ConfigType = ConfigType.DEFAULT): List { return getStorage(configType).getAllKeys() } } /** * 配置类型枚举 */ enum class ConfigType { /** * 使用 RxCache 存储(默认,用于复杂对象) */ RX_CACHE, /** * 使用 Preferences 存储(用于简单键值对) */ PREFERENCES, /** * 使用默认存储(当前为 RxCache) */ DEFAULT } ================================================ FILE: config/src/main/kotlin/cn/netdiscovery/monica/config/storage/ConfigStorage.kt ================================================ package cn.netdiscovery.monica.config.storage /** * 统一配置存储接口 * * 抽象了不同存储实现的差异,提供统一的配置读写接口。 * * @author: Tony Shen * @date: 2025-12-12 */ interface ConfigStorage { /** * 保存配置值 * * @param key 配置键 * @param value 配置值(支持基本类型和可序列化对象) */ fun save(key: String, value: T) /** * 加载配置值 * * @param key 配置键 * @param default 默认值(当配置不存在时返回) * @return 配置值,如果不存在则返回默认值 */ fun load(key: String, default: T): T /** * 检查配置是否存在 * * @param key 配置键 * @return 如果配置存在返回 true,否则返回 false */ fun exists(key: String): Boolean /** * 删除配置 * * @param key 配置键 */ fun remove(key: String) /** * 清空所有配置 */ fun clear() /** * 获取所有配置键 * * @return 配置键列表 */ fun getAllKeys(): List } ================================================ FILE: config/src/main/kotlin/cn/netdiscovery/monica/config/storage/FileConfigStorage.kt ================================================ package cn.netdiscovery.monica.config.storage import com.google.gson.Gson import com.google.gson.reflect.TypeToken import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File import java.nio.file.Files import java.nio.file.StandardOpenOption /** * 文件配置存储适配器(JSON 格式) * * 用于存储 JSON 格式的配置文件(如滤镜参数元数据)。 * 所有配置存储在一个 JSON 文件中。 * * @param configFile 配置文件路径 * @param gson Gson 实例,用于序列化/反序列化 * * @author: Tony Shen * @date: 2025-12-12 */ class FileConfigStorage( private val configFile: File, private val gson: Gson = Gson() ) : ConfigStorage { private val logger: Logger = LoggerFactory.getLogger(FileConfigStorage::class.java) private val configMap: MutableMap by lazy { loadFromFile() } /** * 从文件加载配置 */ private fun loadFromFile(): MutableMap { return if (configFile.exists() && configFile.isFile) { try { val jsonContent = configFile.readText(Charsets.UTF_8) if (jsonContent.isBlank()) { mutableMapOf() } else { val type = object : TypeToken>() {}.type gson.fromJson(jsonContent, type) ?: mutableMapOf() } } catch (e: Exception) { logger.error("Failed to load config from file: ${configFile.absolutePath}", e) mutableMapOf() } } else { // 文件不存在,创建空配置 mutableMapOf() } } /** * 保存配置到文件 */ private fun saveToFile() { try { // 确保父目录存在 configFile.parentFile?.mkdirs() val jsonContent = gson.toJson(configMap) Files.write( configFile.toPath(), jsonContent.toByteArray(Charsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE ) } catch (e: Exception) { logger.error("Failed to save config to file: ${configFile.absolutePath}", e) throw ConfigStorageException("Failed to save config to file", e) } } override fun save(key: String, value: T) { try { configMap[key] = value as Any saveToFile() } catch (e: Exception) { logger.error("Failed to save config with key: $key", e) throw ConfigStorageException("Failed to save config: $key", e) } } @Suppress("UNCHECKED_CAST") override fun load(key: String, default: T): T { return try { val value = configMap[key] if (value != null) { // 尝试类型转换 when { default is String && value is String -> value as T default is Int && value is Number -> value.toInt() as T default is Long && value is Number -> value.toLong() as T default is Float && value is Number -> value.toFloat() as T default is Double && value is Number -> value.toDouble() as T default is Boolean && value is Boolean -> value as T else -> { // 尝试使用 Gson 进行类型转换 val jsonValue = gson.toJson(value) gson.fromJson(jsonValue, default!!::class.java) as T } } } else { default } } catch (e: Exception) { logger.warn("Failed to load config with key: $key, using default value", e) default } } override fun exists(key: String): Boolean { return configMap.containsKey(key) } override fun remove(key: String) { try { configMap.remove(key) saveToFile() } catch (e: Exception) { logger.error("Failed to remove config with key: $key", e) throw ConfigStorageException("Failed to remove config: $key", e) } } override fun clear() { try { configMap.clear() saveToFile() } catch (e: Exception) { logger.error("Failed to clear config storage", e) throw ConfigStorageException("Failed to clear config storage", e) } } override fun getAllKeys(): List { return configMap.keys.toList() } } ================================================ FILE: config/src/main/kotlin/cn/netdiscovery/monica/config/storage/PreferencesConfigStorage.kt ================================================ package cn.netdiscovery.monica.config.storage import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.prefs.Preferences /** * Preferences 配置存储适配器 * * 适配 Java Preferences API,用于存储简单的键值对配置(如语言设置)。 * * @param preferencesNode Preferences 节点,默认为用户节点 * * @author: Tony Shen * @date: 2025-12-12 */ class PreferencesConfigStorage( private val preferencesNode: Preferences = Preferences.userNodeForPackage(PreferencesConfigStorage::class.java) ) : ConfigStorage { private val logger: Logger = LoggerFactory.getLogger(PreferencesConfigStorage::class.java) override fun save(key: String, value: T) { try { when (value) { is String -> preferencesNode.put(key, value) is Int -> preferencesNode.putInt(key, value) is Long -> preferencesNode.putLong(key, value) is Float -> preferencesNode.putFloat(key, value) is Double -> preferencesNode.putDouble(key, value) is Boolean -> preferencesNode.putBoolean(key, value) is ByteArray -> preferencesNode.putByteArray(key, value) else -> { // 对于复杂对象,序列化为 JSON 字符串 preferencesNode.put(key, value.toString()) logger.warn("Complex object serialized as string for key: $key") } } preferencesNode.flush() } catch (e: Exception) { logger.error("Failed to save config with key: $key", e) throw ConfigStorageException("Failed to save config: $key", e) } } @Suppress("UNCHECKED_CAST") override fun load(key: String, default: T): T { return try { when (default) { is String -> preferencesNode.get(key, default) as T is Int -> preferencesNode.getInt(key, default) as T is Long -> preferencesNode.getLong(key, default) as T is Float -> preferencesNode.getFloat(key, default) as T is Double -> preferencesNode.getDouble(key, default) as T is Boolean -> preferencesNode.getBoolean(key, default) as T is ByteArray -> preferencesNode.getByteArray(key, default) as T else -> { val value = preferencesNode.get(key, null) if (value != null) { // 尝试从字符串反序列化(需要类型信息) logger.warn("Complex object deserialization not fully supported for key: $key, returning default") default } else { default } } } } catch (e: Exception) { logger.warn("Failed to load config with key: $key, using default value", e) default } } override fun exists(key: String): Boolean { return try { preferencesNode.get(key, null) != null } catch (e: Exception) { logger.warn("Failed to check existence of config with key: $key", e) false } } override fun remove(key: String) { try { preferencesNode.remove(key) preferencesNode.flush() } catch (e: Exception) { logger.error("Failed to remove config with key: $key", e) throw ConfigStorageException("Failed to remove config: $key", e) } } override fun clear() { try { preferencesNode.clear() preferencesNode.flush() } catch (e: Exception) { logger.error("Failed to clear Preferences", e) throw ConfigStorageException("Failed to clear config storage", e) } } override fun getAllKeys(): List { return try { preferencesNode.keys().toList() } catch (e: Exception) { logger.error("Failed to get all keys from Preferences", e) emptyList() } } } ================================================ FILE: config/src/main/kotlin/cn/netdiscovery/monica/config/storage/RxCacheConfigStorage.kt ================================================ package cn.netdiscovery.monica.config.storage import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.safframework.rxcache.RxCache import com.safframework.rxcache.ext.get import org.slf4j.Logger import org.slf4j.LoggerFactory /** * RxCache 配置存储适配器 * * 适配现有的 RxCache 实现,用于存储复杂对象(如 GeneralSettings)。 * * 注意:由于 RxCache 的 get 方法需要 reified 类型参数,我们使用 Any 类型进行通用处理。 * 对于类型安全的场景,建议使用具体的类型调用。 * * @param rxCache RxCache 实例,由外部传入以避免循环依赖 * * @author: Tony Shen * @date: 2025-12-12 */ class RxCacheConfigStorage( private val rxCache: RxCache ) : ConfigStorage { private val logger: Logger = LoggerFactory.getLogger(RxCacheConfigStorage::class.java) private val gson = Gson() override fun save(key: String, value: T) { try { rxCache.saveOrUpdate(key, value) } catch (e: Exception) { logger.error("Failed to save config with key: $key", e) throw ConfigStorageException("Failed to save config: $key", e) } } @Suppress("UNCHECKED_CAST") override fun load(key: String, default: T): T { return try { // RxCache.get 需要 reified 类型参数,这里使用 Any 作为通用类型 // 实际使用时,类型转换由调用方保证(通过 default 参数的类型推断) val result = rxCache.get(key)?.data if (result != null) { // 尝试类型转换 // 对于基本类型,进行显式转换 when { default is String && result is String -> result as T default is Int && result is Number -> result.toInt() as T default is Long && result is Number -> result.toLong() as T default is Float && result is Number -> result.toFloat() as T default is Double && result is Number -> result.toDouble() as T default is Boolean && result is Boolean -> result as T // 对于复杂对象,检查类型是否匹配 result::class.java.isAssignableFrom(default!!::class.java) -> result as T default::class.java.isAssignableFrom(result::class.java) -> result as T // 如果 result 是 LinkedTreeMap(Gson 反序列化的结果),尝试用 Gson 转换 result is com.google.gson.internal.LinkedTreeMap<*, *> -> { try { val json = gson.toJson(result) @Suppress("UNCHECKED_CAST") gson.fromJson(json, default!!::class.java) as T ?: default } catch (e: Exception) { logger.warn("Failed to convert LinkedTreeMap to ${default!!::class.java.simpleName} for key: $key", e) default } } else -> { logger.warn("Type mismatch for key: $key, expected: ${default!!::class.java.simpleName}, got: ${result::class.java.simpleName}") default } } } else { default } } catch (e: Exception) { logger.warn("Failed to load config with key: $key, using default value", e) default } } override fun exists(key: String): Boolean { return try { rxCache.get(key) != null } catch (e: Exception) { logger.warn("Failed to check existence of config with key: $key", e) false } } override fun remove(key: String) { try { rxCache.remove(key) } catch (e: Exception) { logger.error("Failed to remove config with key: $key", e) throw ConfigStorageException("Failed to remove config: $key", e) } } override fun clear() { try { rxCache.clear() } catch (e: Exception) { logger.error("Failed to clear RxCache", e) throw ConfigStorageException("Failed to clear config storage", e) } } override fun getAllKeys(): List { // RxCache 不直接提供获取所有键的接口,返回空列表 // 如果需要此功能,可以考虑维护一个键列表 logger.warn("RxCache does not support getAllKeys(), returning empty list") return emptyList() } } /** * 配置存储异常 */ class ConfigStorageException(message: String, cause: Throwable? = null) : Exception(message, cause) ================================================ FILE: docs/filter_module_refactor.md ================================================ # 滤镜模块 UI 重构与优化说明(2025-12) ## 背景与目标 本次重构的核心目标: - **提升 UI 可用性与一致性**:对齐、间距、状态提示更清晰,符合图像编辑软件的交互预期。 - **保持业务逻辑不变/可控演进**:在不破坏滤镜算法实现的前提下,整理 UI 状态与交互。 - **提升性能与稳定性**:拖动体验更顺滑,避免频繁计算与 CPU 抖动;修复已知崩溃点与错位问题。 --- ## 关键交互语义(最终形态) ### 1)拖动即提交(去掉 Apply 按钮) - **滤镜选择**:点击某个滤镜后,立即在编辑器画布上应用一次(并记录一次历史)。 - **参数调整**: - 拖动过程中:仍会以 **300ms 抽样**方式触发预览(降低计算频率)。 - 松手后:**立即提交**到编辑器画布(并记录一次历史),避免多次历史碎片化。 - 文本输入:输入过程只做预览;按 `Done` 后提交一次。 > 说明:提交时使用“进入滤镜模块前的基线图”作为输入,避免在 `currentImage` 上反复叠加导致效果漂移。 ### 2)Reset / Cancel / 清除滤镜 - **Reset(重置滤镜)**:恢复当前滤镜的默认参数,并立即提交一次(记录历史)。 - **清除滤镜**:恢复到进入滤镜模块前的效果(基线图),记录一次历史,并取消滤镜选中态。 - **Cancel**:用于取消未提交的预览态(例如仅有 previewImage),回到上次提交参数快照并清理预览。 --- ## UI 结构拆分与状态管理 ### 1)去全局状态(多实例安全) 移除文件级全局变量 `filterSelectedIndex` / `filterTempMap`,改为在 `filter()` 内使用 `remember` 状态: - `selectedIndexState` - `paramMap`(`mutableStateMapOf`) - `appliedParamSnapshot` - `baseImageSnapshot`(进入模块前基线图) 避免了多窗口/多次进入模块时状态串扰的问题。 ### 2)右侧面板底栏固定 修复了右侧面板滚动区域占满高度导致底部按钮区域不可见的问题:滚动区使用 `weight(1f)`,底栏固定展示。 ### 3)收起时参数摘要 当参数区收起时,展示“参数摘要”卡片: - 默认/已调整项数量 - 展示部分差异项 - Reset 提示(引导用户使用底部按钮) --- ## 参数范围与格式化(配置化) 新增参数 UI 元信息: - `FilterParamMeta(min, max, step, decimals)` - `FilterParamMetaRegistry.resolve(filterName, param)`:统一解析范围、步长、显示小数位。 并新增默认参数构建工具: - `buildDefaultParamMap(filterName)`:用于初始化/Reset/判断是否处于默认参数状态。 - Float/Double 默认值按 `decimals` 统一格式化,避免 UI 显示不一致。 ### BlockFilter 安全修复(step=0 崩溃) 问题:`BlockFilter` 内部将 `blockSize` 用于 `range.step(blockSize)`,当 `blockSize=0` 会直接抛异常。 修复策略(三道防线): 1. `FilterParamMetaRegistry` 为 `blockSize` 设置 `min=1`。 2. 默认参数构建时按 meta.min 对 Int 进行 clamp(即使缓存里有 0 也会被纠正)。 3. `BlockFilter` 构造函数内防御性修复:`max(1, blockSize)`,彻底杜绝 crash。 --- ## 性能优化 ### 1)Slider 抽样预览(300ms)+ 松手提交 拖动过程中不实时提交,降低重算频率;松手后一次性提交,历史更干净。 ### 2)预览缓存(同滤镜 + 同参数 hash 命中) 在 `FilterViewModel.applyFilterPreview()` 中引入 LRU 预览缓存: - Key:`baseImageId(identityHashCode) + filterName + paramsHash(稳定排序后 hash)` - 策略:LRU + 双阈值淘汰(条目数 + 估算内存上限) - `clear()` 时会清空缓存,避免跨页面持有内存 收益:重复参数回退/来回拖动时命中缓存,CPU 更稳、预览更顺滑。 --- ## Bug 修复汇总 - **搜索列表点击/选中错位**:修复 `itemsIndexed` 使用位置 index 误当真实 filterIndex 的问题。 - **英文硬编码**:如 `No Image` 等占位文案改为 i18n。 - **右侧按钮不显示**:滚动区域 `fillMaxSize()` 挤掉底栏的问题修复为 `weight(1f)`。 - **BlockFilter step=0 崩溃**:如上“三道防线”修复,并处理缓存里持久化为 0 的脏数据。 --- ## 国际化(i18n)新增/补充 Key(节选) - `no_image` / `no_filters_found` - `param_summary` / `param_summary_default` / `param_summary_changed_count` / `param_summary_reset_hint` - `clear_filter` --- ## 涉及文件清单(主要) - `src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/FilterView.kt` - `src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/FilterListPanel.kt` - `src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/FilterPreviewArea.kt` - `src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/FilterAdjustmentPanel.kt` - `src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/FilterViewModel.kt` - `src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/FilterParamMeta.kt` - `src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/FilterParamDefaults.kt` - `imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/BlockFilter.kt` - `i18n/src/main/resources/strings/strings_zh.xml` - `i18n/src/main/resources/strings/strings_en.xml` --- ## 已知未完成项 / 后续建议 - **导出功能**:`FilterTopAppBar` 的 Export 仍为 TODO。 - **日志与可观测性**:当前仍存在少量 `logger.info(...)`(如 FilterView 生命周期/FilterViewModel applyFilter)。建议后续统一降噪或增加 debug 开关。 - **可访问性**:个别 `contentDescription` 仍为英文(如 Zoom In),可按 i18n 统一。 --- ## 未来优化方向(路线图建议) ### P0(高收益 / 低风险,建议优先) - **提交任务的并发与取消策略** - 当前“松手即提交”在用户频繁操作时可能产生提交排队;建议在提交前取消上一次未完成的提交任务,仅保留最后一次松手的提交(类似“last-write-wins”)。 - 预览任务与提交任务建议分别管理,避免互相 cancel 造成 UI 抖动。 - **预览缓存进一步完善** - 目前缓存 key 基于 `identityHashCode(baseImage)`;若后续引入“滤镜叠加链”,可扩展为 `baseImageFingerprint + chainHash + paramsHash`(或至少在 sourceImageOverride 场景下保证 cacheKey 取对)。 - 可增加简单命中率统计(默认关闭),便于性能回归。 - **枚举型参数的系统化支持** - 已对 `ColorFilter.style`、`NatureFilter.style` 做了下拉选择;建议把更多类似参数(如 `gridType`、`waveType` 等)统一纳入 `FilterParamMetaRegistry` 的 `enumOptions`。 - 枚举项建议改为外部配置(json),降低 Kotlin 侧维护成本。 ### P1(高收益 / 中等工程量) - **非破坏式滤镜栈(专业编辑器体验)** - 当前“方式1”支持滤镜叠加,但本质是“破坏式”写回 `currentImage`;建议升级为滤镜栈(A→B→C): - UI:支持新增/删除/排序/启用/禁用滤镜条目; - 计算:以基线图为输入重算整条链(可配合分段缓存); - 历史:一次“应用/确认”生成一个历史节点,或者按滤镜条目粒度记录。 - 优点:可编辑、可回溯、符合 PS/Lightroom 预期;缺点:需要明确栈的存储与性能策略。 - **参数元数据完全配置化** - 将 `FilterParamMeta`(min/max/step/decimals/enumOptions)迁移到 `resources/common/filterParamMeta.json`(或扩展现有 `filterConfig.json`),Kotlin 只保留类型默认兜底与少量安全约束(例如 step>0)。 - 这样可以由产品/算法侧直接调整范围与枚举定义,不需要改代码。 - **提交与撤销语义更精确** - 当前每次松手都会 push 历史;可考虑“合并提交窗口”(例如 500ms 内多次提交合并为一次历史),减少 undo 栈污染。 - 也可增加“预览模式”开关:只预览不入历史,用户确认后再统一落盘。 ### P2(体验增强 / 可持续维护) - **导出能力落地(与滤镜结果一致)** - Export 需要明确导出的是:当前画布效果(含叠加)还是仅某个滤镜结果。 - 建议支持:导出当前效果 / 导出原图 / 导出带滤镜栈元数据(用于二次编辑)。 - **测试与回归保障** - 为关键交互补充自动化验证:列表筛选点击不乱、BlockFilter step 不为 0、枚举下拉可用、缓存命中不串图、清除滤镜恢复正确。 - **可访问性与键盘操作** - 下拉/按钮/缩放控件补齐 i18n 的 `contentDescription`; - 为参数控件支持键盘上下调整与快捷键(更像桌面编辑器)。 ================================================ FILE: docs/layer_render_cache_analysis.md ================================================ # 图层渲染缓存优化分析 ## 当前实现分析 ### 1. 渲染流程 - `CanvasView` 使用 `collectAsState()` 观察图层列表 - 每次图层变化都会触发 Canvas 重组和重绘 - `LayerRenderer.drawAll()` 遍历所有图层并调用 `render()` - 每个图层都使用 `drawIntoCanvas` + `saveLayer`,有性能开销 ### 2. 性能瓶颈 - **ImageLayer**: 每次重绘都重新计算变换(平移、旋转、缩放) - **ShapeLayer**: 每次重绘都重新绘制所有形状 - **Canvas 重组**: 图层列表变化时,整个 Canvas 都会重组 ## 优化方案对比 ### 方案一:路线图中的 ImageBitmap 缓存(不推荐) **问题**: 1. Compose 的 `DrawScope` 在每次重组时都会重新创建,不能直接缓存 `ImageBitmap` 2. 缓存需要考虑画布尺寸变化(Canvas 尺寸可能变化) 3. 需要考虑透明度、变换等属性的变化 4. 缓存失效逻辑复杂(何时清除缓存?) **适用场景**: - 静态图像,尺寸固定 - 不适用于动态变化的图层 ### 方案二:Compose 级别的缓存(推荐)⭐ **核心思路**: 1. 使用 `remember` + `key()` 为每个图层创建独立的缓存 2. 使用 `Modifier.drawWithCache` 缓存绘制内容 3. 使用版本号/哈希值标记图层变化 **优势**: - 利用 Compose 的缓存机制,自动管理生命周期 - 画布尺寸变化时自动失效 - 代码简洁,易于维护 **实现要点**: ```kotlin @Composable fun LayerRenderer( layers: List, canvasSize: Size ) { layers.forEach { layer -> key(layer.id, layer.version) { // 版本号标记变化 DrawScope.drawWithCache { // 缓存绘制内容 onDrawBehind { layer.render(this) } } } } } ``` ### 方案三:分层缓存策略(最佳)⭐⭐ **核心思路**: 1. **ImageLayer**: 缓存变换后的图像(使用 `drawWithCache`) 2. **ShapeLayer**: 使用 `remember` 缓存形状列表,避免重复计算 3. **组合优化**: 只重绘变化的图层区域 **实现要点**: #### 1. Layer 基类添加版本号 ```kotlin abstract class Layer { private var _version by mutableStateOf(0L) val version: Long get() = _version protected fun markDirty() { _version++ } // 属性变化时调用 fun updateOpacity(alpha: Float) { opacity = alpha.coerceIn(0f, 1f) markDirty() } } ``` #### 2. ImageLayer 缓存变换结果 ```kotlin @Composable fun ImageLayerRenderer( layer: ImageLayer, canvasSize: Size ) { val cachedImage = remember(layer.id, layer.version, canvasSize) { // 计算变换后的图像 renderToBitmap(layer, canvasSize) } Canvas(modifier = Modifier.drawWithCache { onDrawBehind { drawImage(cachedImage) } }) } ``` #### 3. ShapeLayer 缓存形状列表 ```kotlin @Composable fun ShapeLayerRenderer( layer: ShapeLayer, canvasSize: Size ) { val shapes = remember(layer.id, layer.version) { // 缓存形状列表,避免重复计算 layer.getAllShapes() } Canvas(modifier = Modifier) { shapes.forEach { shape -> drawShape(shape) } } } ``` #### 4. LayerRenderer 优化 ```kotlin class LayerRenderer { fun drawAll(drawScope: DrawScope, layers: List) { layers.forEach { layer -> if (!layer.visible || layer.opacity <= 0f) return@forEach // 使用 key 确保只有变化的图层才重绘 key(layer.id, layer.version) { drawLayer(drawScope, layer) } } } } ``` ## 性能提升预估 ### 当前性能 - 10 个图层,每次重绘耗时:~50ms - 拖动图像层时:~16ms/frame(60fps 可能卡顿) ### 优化后性能 - 10 个图层,未变化时:~5ms(使用缓存) - 拖动图像层时:~8ms/frame(只重绘变化的图层) **提升**:约 5-10 倍性能提升 ## 实施建议 ### 阶段一:基础优化(2-3 天) 1. 在 `Layer` 基类添加 `version` 字段 2. 属性变化时调用 `markDirty()` 3. 使用 `key()` 优化 Compose 重组 ### 阶段二:ImageLayer 缓存(2-3 天) 1. 使用 `remember` 缓存变换后的图像 2. 使用 `Modifier.drawWithCache` 缓存绘制内容 3. 处理画布尺寸变化 ### 阶段三:ShapeLayer 优化(1-2 天) 1. 缓存形状列表 2. 优化形状绘制逻辑 ### 阶段四:增量渲染(可选,3-5 天) 1. 只重绘变化的图层区域 2. 使用脏矩形技术 ## 注意事项 1. **内存管理**: - 缓存会占用内存,需要设置上限 - 使用 `SoftReference` 或 LRU 缓存 2. **缓存失效**: - 画布尺寸变化时自动失效 - 图层属性变化时手动失效 3. **兼容性**: - 确保与现有代码兼容 - 不影响导出功能 4. **测试**: - 测试大量图层场景 - 测试频繁变化场景 - 测试内存使用情况 ## 结论 **推荐方案**:方案三(分层缓存策略) - 性能提升明显 - 实现相对简单 - 易于维护和扩展 - 符合 Compose 最佳实践 **预计工作量**:5-7 天(与路线图一致) **优先级**:中优先级(当前性能可接受,但优化后体验更好) ================================================ FILE: docs/layer_system.md ================================================ # 图层系统概览 本文档记录 Monica 图层系统的最新实现,用于指导后续的功能扩展与维护。 ## 核心目标 - 支持图像层与形状层的叠加管理,便于多图层编辑。 - 统一渲染与导出流程,避免重复绘制逻辑。 - 提供直观的 UI 面板,用于图层的增删、排序、锁定与重命名。 ## 主要模块 | 模块 | 关键文件 | 功能 | | --- | --- | --- | | 图层抽象 | `ui/controlpanel/shapedrawing/layer/Layer.kt` | 统一的图层基类,封装名称、可见性、透明度、锁定状态等属性。 | | 图层管理 | `ui/controlpanel/shapedrawing/layer/LayerManager.kt` | 负责图层增删改查、排序、激活状态同步,提供监听机制。使用 `StateFlow` 实现响应式更新。 | | 图像层 | `ui/controlpanel/shapedrawing/layer/ImageLayer.kt` | 保存背景位图及平移、缩放、旋转等变换信息。支持自动适应画布并居中显示。 | | 形状层 | `ui/controlpanel/shapedrawing/layer/ShapeLayer.kt` | 承载形状绘制数据(线段、矩形、多边形、文本等)。当前限制最多创建 1 个形状层。 | | 渲染器 | `ui/controlpanel/shapedrawing/layer/LayerRenderer.kt` | 顺序遍历图层并绘制到 Compose `DrawScope`,支持透明度合成。 | | 控制器 | `ui/controlpanel/shapedrawing/EditorController.kt` | 整合管理器、渲染器、导出流程,并暴露工具切换、图层同步接口。限制形状层数量为 1。导出逻辑内联在控制器中,提供 `exportImageBitmap()` 和 `exportBufferedImage()` 方法。 | ## 工作流 ``` 用户交互 → EditorController → LayerManager → LayerRenderer → Canvas ↓ 导出方法(内联在 EditorController 中) ``` 1. UI 侧(例如 `ShapeDrawingView`)通过 `EditorController` 获取或创建图层。 2. 用户绘制的形状实时写入当前激活的 `ShapeLayer`。 3. `CanvasView`(`ui/controlpanel/shapedrawing/widget/CanvasView.kt`)调用 `LayerRenderer.drawAll()` 依次绘制每个图层,并根据透明度应用 `saveLayer`。 4. 导出功能复用渲染器,将所有图层合成为位图或 AWT 图像。 ## UI 面板 文件:`ui/controlpanel/shapedrawing/widget/LayerPanel.kt` - 左侧卡片式列表展示所有图层(顶部为最新图层)。 - 支持: - 可见性勾选 - 锁定/解锁(锁定后图标变红) - 重命名(内联编辑) - 上移/下移排序 - 新建形状层(按钮显示"已达上限"当达到限制时) - 新建图像层 - 激活的图层使用浅色高亮和边框,提供即时视觉反馈。 - 当前激活的形状层显示"• 当前绘制"标识。 ## 关键交互 - **初始化背景层**:`ShapeDrawingView`(`ui/controlpanel/shapedrawing/ShapeDrawingView.kt`)在载入图像时,通过 `LaunchedEffect(imageBitmap)` 从 `LayerManager` 中查找名为"背景图层"的图层,如果不存在则创建,如果存在则更新图像。确保状态同步,避免使用本地状态变量。 - **形状写入**:每次拖动事件结束后,调用 `EditorController.replaceShapesInActiveLayer` 更新层数据。如果形状层已锁定,则禁止写入。在 `onDrag` 和 `onDragEnd` 中都会调用 `syncShapeLayer()` 同步形状数据。 - **图像层拖动**:当激活图层为图像层且未锁定时,可以直接拖动图像层调整位置。拖动时更新 `LayerTransform.translation`,该变换会在自动适应和居中之后应用。 - **导出**:点击保存按钮时,使用 `EditorController.exportBufferedImage` 获取合成结果。导出时使用显示尺寸(`ImageSizeCalculator.getImageDisplayPixelSize`,位于 `ui/widget/image/ImageSizeCalculator.kt`),并考虑 Canvas padding(8.dp),确保导出结果与显示效果一致。 ## 设计决策 ### 形状层限制 - **限制数量**:当前实现限制最多创建 1 个形状层(`MAX_SHAPE_LAYERS = 1`),简化设计,避免多形状层带来的复杂性。 - **图像层无限制**:支持创建多个图像层,每个图像层可以独立拖动和变换。 ### 背景层识别 - **识别方式**:通过图层名称 `"背景图层"` 来识别背景层,而不是通过图像尺寸或其他属性。这种方式更可靠,不受图像尺寸变化影响。 - **渲染差异**: - 背景层:只应用自动适应和居中(`fitScale` 和 `centerOffset`),不应用用户定义的变换(`transform.translation`、`transform.rotation`、`transform.scaleX/Y`)。 - 用户添加的图像层:先应用自动适应和居中,再应用用户定义的变换。这样可以确保图像层在自动适应后,用户还可以进一步调整位置、旋转和缩放。 ### 坐标系统 - **统一坐标**:使用显示尺寸(`ImageSizeCalculator.getImageDisplayPixelSize`,位于 `ui/widget/image/ImageSizeCalculator.kt`)作为坐标基准,确保绘制、显示和导出的一致性。导出时也会使用相同的显示尺寸,并减去 Canvas padding(8.dp × 2 = 16.dp),确保导出结果与显示效果完全一致。 - **坐标转换器**:`CoordinateConverter`(`ui/controlpanel/shapedrawing/coordinate/CoordinateConverter.kt`)通过 `remember(state.currentImage, density.density)` 创建,当图像或密度变化时会自动重新计算转换比例,确保坐标转换的准确性。 ### 安全保护 - **除零保护**:`ImageLayer.render()` 中在计算缩放比例前检查 `bitmap.width`、`bitmap.height`、`canvasWidth`、`canvasHeight` 是否大于 0,如果任一值为 0 或负数则直接返回,防止除零错误。 - **锁定检查**: - 在 `EditorController.addShapeToActiveLayer` 和 `replaceShapesInActiveLayer` 中检查形状层是否锁定,锁定状态下禁止修改。 - 在 `EditorController.canDrawOnActiveShapeLayer()` 中检查当前激活的形状层是否锁定,用于 UI 交互前的验证。 - 在 `ShapeDrawingView` 的拖动事件处理中,如果形状层已锁定,会显示提示并阻止绘制操作。 ## 测试覆盖 | 测试文件 | 覆盖点 | | --- | --- | | `src/jvmTest/kotlin/cn/netdiscovery/monica/editor/layer/LayerManagerTest.kt` | 图层添加、激活同步、排序、清空等行为。 | | `src/jvmTest/kotlin/cn/netdiscovery/monica/editor/layer/ExportManagerTest.kt` | 图像层合成正确性(导出功能测试,类名为 `EditorControllerExportTest`)。 | > 当前测试依赖 `kotlin("test")`,位于 `build.gradle.kts` 的 `jvmTest` SourceSet 中。 ## 已知问题与修复 ### 已修复的问题 1. **除零错误保护**(2024-12) - 问题:`ImageLayer.render()` 在 `bitmap.width` 或 `bitmap.height` 为 0 时可能发生除零错误。 - 修复:添加安全检查,在渲染前验证尺寸有效性。 2. **坐标转换器不更新**(2024-12) - 问题:`CoordinateConverter` 使用 `remember` 无依赖项,图像尺寸变化时不更新。 - 修复:添加 `state.currentImage` 和 `density.density` 作为依赖项。 3. **背景层状态同步**(2024-12) - 问题:使用本地 `backgroundLayer` 状态变量(`remember { mutableStateOf(null) }`),与 `LayerManager` 不同步。如果用户通过其他方式修改了背景层,本地状态不会更新。 - 修复:移除了本地状态变量,改为在 `LaunchedEffect(imageBitmap)` 中直接从 `LayerManager.layers.value` 查找背景层,确保状态一致性。 4. **导出尺寸不一致**(2024-12) - 问题:导出时使用原始像素尺寸,与显示尺寸不一致。 - 修复:导出时使用显示尺寸,并考虑 Canvas padding(8.dp),确保导出结果与显示效果一致。 ## 后续待办 - 形状层透明度、混合模式等高级属性。 - 图层拖拽排序(UI 交互层面,当前仅支持上移/下移按钮)。 - 控制器与其它工具模块(涂鸦、滤镜等)的整合策略。 - 渲染性能评估与缓存机制。 - 背景层删除保护(如果未来在 `LayerPanel` 中添加删除功能,需要防止删除背景层)。 - 图像层的旋转和缩放交互(当前仅支持拖动位置)。 > 📋 **详细优化路线图**: 请参考 [图层系统优化路线图](./layer_system_optimization_roadmap.md) 获取完整的优化计划、实施细节和时间估算。 如需扩展新的图层类型,建议: 1. 新建 `Layer` 子类,实现数据结构与 `render()`。 2. 在 `LayerRenderer` 中添加对应的绘制分支。 3. 在 `LayerPanel` 中增加图标、操作项。 4. 补充单元测试覆盖新增逻辑。 ================================================ FILE: docs/layer_system_optimization_roadmap.md ================================================ # 图层系统优化路线图 本文档记录 Monica 图层系统的优化方向和实施计划,用于指导后续的功能扩展与性能提升。 **文档版本**: 1.0 **最后更新**: 2024-12 **维护者**: Monica 开发团队 --- ## 📋 目录 - [一、功能扩展](#一功能扩展) - [二、性能优化](#二性能优化) - [三、代码质量提升](#三代码质量提升) - [四、架构优化](#四架构优化) - [五、用户体验改进](#五用户体验改进) - [六、实施计划](#六实施计划) - [七、技术债务清理](#七技术债务清理) - [八、监控与评估](#八监控与评估) --- ## 一、功能扩展 ### 1.1 UI 交互增强 #### 1.1.1 图层删除功能 ⭐ 高优先级 **当前状态**: `LayerPanel` 中没有删除按钮 **目标**: - 在图层卡片中添加删除按钮(垃圾桶图标) - 删除前显示确认对话框 - 防止误删除重要图层 **实施要点**: ```kotlin // 在 LayerPanel.kt 中添加删除按钮 IconButton( onClick = { if (layer.name == "背景图层") { state.showTray("无法删除背景图层", "提示") } else { // 显示确认对话框 showDeleteConfirmDialog = true } } ) { Icon(Icons.Default.Delete, "删除图层") } ``` **技术细节**: - 添加背景层删除保护机制 - 删除后自动激活上一个图层 - 支持撤销删除(如果实现撤销/重做功能) **预计工作量**: 2-3 天 --- #### 1.1.2 拖拽排序 ⭐ 高优先级 **当前状态**: 仅支持上移/下移按钮 **目标**: - 实现图层卡片拖拽排序 - 提供更直观的交互体验 **实施要点**: ```kotlin // 使用 Compose 的拖拽 API Modifier .pointerInput(Unit) { detectDragGestures { change, dragAmount -> // 处理拖拽逻辑 } } ``` **技术细节**: - 使用 `Modifier.draggable()` 或 `Modifier.pointerInput()` 实现拖拽 - 拖拽时显示视觉反馈(高亮、阴影) - 拖拽结束后更新图层顺序 **预计工作量**: 3-5 天 --- #### 1.1.3 图层缩略图预览 **当前状态**: 图层卡片仅显示类型图标 **目标**: - 在图层卡片中显示缩略图 - 提升图层识别度 **实施要点**: - 为 `ImageLayer` 生成缩略图(缓存) - 为 `ShapeLayer` 生成预览图 - 使用 `remember` 缓存缩略图,避免重复计算 **预计工作量**: 2-3 天 --- ### 1.2 图像层交互增强 #### 1.2.1 旋转和缩放交互 ⭐ 高优先级 **当前状态**: 仅支持拖动位置 **目标**: - 添加旋转手柄和控制点 - 支持鼠标滚轮缩放 - 支持右键旋转 **实施要点**: ```kotlin // 在图像层周围添加控制点 data class ImageLayerControls( val translation: Offset, val rotation: Float, val scale: Float, val pivot: Offset ) // 添加交互处理 fun handleImageLayerTransform( layerId: UUID, transformType: TransformType, value: Float ) ``` **技术细节**: - 在 Canvas 上绘制控制点和旋转手柄 - 检测鼠标悬停和拖动 - 更新 `LayerTransform` 的 `rotation` 和 `scaleX/Y` **预计工作量**: 5-7 天 --- #### 1.2.2 图像层裁剪 **当前状态**: 不支持裁剪 **目标**: - 支持裁剪区域选择 - 添加遮罩功能 **实施要点**: - 在 `ImageLayer` 中添加 `cropRect` 属性 - 渲染时应用裁剪区域 - UI 上显示裁剪控制点 **预计工作量**: 7-10 天 --- ### 1.3 形状层功能扩展 #### 1.3.1 解除形状层数量限制(可选) **当前状态**: 限制最多 1 个形状层(`MAX_SHAPE_LAYERS = 1`) **目标**: - 评估是否需要支持多个形状层 - 如需要,重构相关逻辑 **考虑因素**: - 用户需求是否强烈 - 实现复杂度 - 对现有代码的影响 **预计工作量**: 5-10 天(取决于重构范围) --- #### 1.3.2 形状层分组 **当前状态**: 不支持分组 **目标**: - 支持形状分组管理 - 分组级别的可见性/锁定控制 **预计工作量**: 10-15 天 --- ## 二、性能优化 ### 2.1 渲染性能优化 #### 2.1.1 图层渲染缓存 ⭐ 中优先级 **当前状态**: 每次重绘都重新渲染所有图层 **目标**: - 对未变化的图层使用缓存 - 减少不必要的重绘 **实施要点**: ```kotlin class LayerRenderer { private val renderCache = mutableMapOf() fun drawAll(drawScope: DrawScope, layers: List) { layers.forEach { layer -> if (layer.isDirty) { renderCache.remove(layer.id) layer.isDirty = false } val cached = renderCache[layer.id] if (cached != null && !layer.isDirty) { // 使用缓存 drawScope.drawImage(cached) } else { // 重新渲染并缓存 val rendered = renderLayer(layer, drawScope) renderCache[layer.id] = rendered } } } } ``` **技术细节**: - 在 `Layer` 基类中添加 `isDirty` 标记 - 图层属性变化时设置 `isDirty = true` - 使用 `remember` 在 Compose 中缓存渲染结果 **预计工作量**: 5-7 天 --- #### 2.1.2 增量渲染 **当前状态**: 所有图层每次都重绘 **目标**: - 只重绘变化的图层 - 优化 Compose 重组 **实施要点**: - 使用 `LaunchedEffect` 监听图层变化 - 只更新变化的图层区域 - 使用 `Modifier.drawWithCache` 优化绘制 **预计工作量**: 7-10 天 --- #### 2.1.3 大图像优化 **当前状态**: 可能对超大图像性能不佳 **目标**: - 对超大图像使用缩略图预览 - 导出时使用全分辨率 **实施要点**: - 在 `ImageLayer` 中维护缩略图 - 渲染时使用缩略图,导出时使用原图 - 实现渐进式加载 **预计工作量**: 5-7 天 --- ### 2.2 内存优化 #### 2.2.1 图像层内存管理 **目标**: - 实现图像压缩/解压缩策略 - 对不可见图层延迟加载 **实施要点**: - 使用 `SoftReference` 缓存图像 - 实现 LRU 缓存策略 - 对不可见图层不加载到内存 **预计工作量**: 7-10 天 --- #### 2.2.2 形状数据优化 **目标**: - 使用更高效的数据结构 - 考虑使用 `Path` 对象缓存 **实施要点**: - 评估当前 `SnapshotStateMap` 的性能 - 考虑使用 `Path` 对象缓存复杂形状 - 实现形状数据的序列化/反序列化 **预计工作量**: 3-5 天 --- ## 三、代码质量提升 ### 3.1 测试覆盖 #### 3.1.1 单元测试扩展 **当前状态**: 已有基础测试(`LayerManagerTest.kt`、`ExportManagerTest.kt`) **目标**: - 提高测试覆盖率到 80% 以上 - 覆盖边界情况和异常情况 **需要测试的场景**: - `ImageLayer.render()` 的边界情况(零尺寸、空图像等) - `LayerManager` 的并发安全测试 - 坐标转换器的各种场景 - 图层变换的数学计算 **预计工作量**: 10-15 天 --- #### 3.1.2 集成测试 **目标**: - 图层合成导出测试 - UI 交互测试 **实施要点**: - 使用 Compose 测试框架 - 测试图层操作的完整流程 - 测试导出结果的正确性 **预计工作量**: 7-10 天 --- ### 3.2 错误处理 #### 3.2.1 异常处理完善 **目标**: - 图像加载失败处理 - 渲染错误恢复机制 **实施要点**: ```kotlin // 在 ImageLayer 中添加错误处理 fun updateImage(newImage: ImageBitmap?) { try { image = newImage } catch (e: Exception) { logger.error("更新图像失败", e) // 显示错误提示 // 恢复上一个有效图像 } } ``` **预计工作量**: 3-5 天 --- #### 3.2.2 用户反馈改进 **目标**: - 更明确的错误提示 - 操作成功/失败的 Toast 提示 **实施要点**: - 统一错误消息格式 - 添加操作成功提示 - 提供错误恢复建议 **预计工作量**: 2-3 天 --- ### 3.3 代码重构 #### 3.3.1 背景层管理抽象 **当前状态**: 背景层管理逻辑分散在 `ShapeDrawingView` 中 **目标**: - 创建 `BackgroundLayerManager` 统一管理背景层 **实施要点**: ```kotlin class BackgroundLayerManager( private val layerManager: LayerManager ) { private val BACKGROUND_LAYER_NAME = "背景图层" fun getOrCreateBackgroundLayer(image: ImageBitmap): ImageLayer { val existing = layerManager.layers.value .firstOrNull { it.name == BACKGROUND_LAYER_NAME && it is ImageLayer } as? ImageLayer return existing ?: run { val newLayer = ImageLayer(BACKGROUND_LAYER_NAME, image) layerManager.addLayer(newLayer, index = 0) newLayer } } fun updateBackgroundLayer(image: ImageBitmap) { val layer = getOrCreateBackgroundLayer(image) layer.updateImage(image) } } ``` **预计工作量**: 2-3 天 --- #### 3.3.2 图层操作命令模式 **目标**: - 实现撤销/重做功能 - 使用命令模式封装图层操作 **实施要点**: ```kotlin interface LayerCommand { fun execute() fun undo() } class AddLayerCommand( private val layerManager: LayerManager, private val layer: Layer ) : LayerCommand { override fun execute() { layerManager.addLayer(layer) } override fun undo() { layerManager.removeLayer(layer.id) } } class CommandManager { private val undoStack = mutableListOf() private val redoStack = mutableListOf() fun execute(command: LayerCommand) { command.execute() undoStack.add(command) redoStack.clear() } fun undo() { if (undoStack.isNotEmpty()) { val command = undoStack.removeLast() command.undo() redoStack.add(command) } } fun redo() { if (redoStack.isNotEmpty()) { val command = redoStack.removeLast() command.execute() undoStack.add(command) } } } ``` **预计工作量**: 10-15 天 --- ## 四、架构优化 ### 4.1 图层类型扩展 #### 4.1.1 文本层(独立图层类型) **当前状态**: 文本在形状层中 **目标**: - 创建独立的 `TextLayer` 类型 - 提供更专业的文本编辑功能 **实施要点**: ```kotlin class TextLayer( name: String, var text: String = "", var font: Font = Font.Default, var fontSize: Float = 16f, var color: Color = Color.Black, var position: Offset = Offset.Zero ) : Layer( type = LayerType.TEXT, name = name ) { override fun render(drawScope: DrawScope) { // 文本渲染逻辑 } } ``` **预计工作量**: 7-10 天 --- #### 4.1.2 调整层(Adjustment Layer) **目标**: - 亮度、对比度、色彩调整 - 不影响原始图像数据 **实施要点**: ```kotlin class AdjustmentLayer( name: String, var brightness: Float = 0f, var contrast: Float = 1f, var saturation: Float = 1f ) : Layer( type = LayerType.ADJUSTMENT, name = name ) { override fun render(drawScope: DrawScope) { // 应用调整效果到下层图层 } } ``` **预计工作量**: 15-20 天 --- #### 4.1.3 滤镜层 **目标**: - 模糊、锐化等效果 - 可叠加多个滤镜 **实施要点**: ```kotlin enum class FilterType { BLUR, SHARPEN, EMBOSS, // ... } class FilterLayer( name: String, var filterType: FilterType, var intensity: Float = 1f ) : Layer( type = LayerType.FILTER, name = name ) ``` **预计工作量**: 20-30 天 --- ### 4.2 混合模式支持 #### 4.2.1 实现混合模式 **目标**: - 支持多种混合模式(Normal、Multiply、Screen 等) - 在图层合成时应用混合模式 **实施要点**: ```kotlin enum class BlendMode { NORMAL, MULTIPLY, SCREEN, OVERLAY, SOFT_LIGHT, HARD_LIGHT, COLOR_DODGE, COLOR_BURN, DARKEN, LIGHTEN, DIFFERENCE, EXCLUSION } class Layer { var blendMode: BlendMode = BlendMode.NORMAL } // 在 LayerRenderer 中应用混合模式 fun drawAll(drawScope: DrawScope, layers: List) { layers.forEach { layer -> drawScope.drawIntoCanvas { canvas -> // 应用混合模式 val paint = Paint().apply { blendMode = when (layer.blendMode) { BlendMode.NORMAL -> BlendMode.SrcOver BlendMode.MULTIPLY -> BlendMode.Multiply // ... } } // 绘制图层 } } } ``` **预计工作量**: 15-20 天 --- ### 4.3 图层组(Layer Group) #### 4.3.1 实现图层分组 **目标**: - 支持图层分组 - 组级别的可见性/锁定控制 - 嵌套分组支持 **实施要点**: ```kotlin class LayerGroup( name: String, val children: MutableList = mutableListOf() ) : Layer( type = LayerType.GROUP, name = name ) { fun addChild(layer: Layer) { children.add(layer) } fun removeChild(layerId: UUID) { children.removeAll { it.id == layerId } } override fun render(drawScope: DrawScope) { if (!visible) return children.forEach { child -> if (child.visible) { child.render(drawScope) } } } } ``` **预计工作量**: 20-30 天 --- ## 五、用户体验改进 ### 5.1 快捷键支持 ⭐ 高优先级 #### 5.1.1 实现常用快捷键 **目标**: - 提供键盘快捷键支持 - 提升操作效率 **快捷键列表**: - `Ctrl/Cmd + D` - 复制图层 - `Delete` / `Backspace` - 删除图层 - `Ctrl/Cmd + G` - 创建图层组 - `Ctrl/Cmd + Shift + N` - 新建图层 - `Ctrl/Cmd + J` - 复制并新建图层 - `Ctrl/Cmd + Shift + ]` - 图层上移 - `Ctrl/Cmd + Shift + [` - 图层下移 - `Ctrl/Cmd + Z` - 撤销(如果实现) - `Ctrl/Cmd + Shift + Z` - 重做(如果实现) **实施要点**: ```kotlin // 在 ShapeDrawingView 中添加键盘事件处理 Modifier.onKeyEvent { keyEvent -> when { keyEvent.isCtrlPressed && keyEvent.key == Key.D -> { // 复制图层 true } keyEvent.key == Key.Delete -> { // 删除图层 true } else -> false } } ``` **预计工作量**: 5-7 天 --- ### 5.2 图层搜索/过滤 #### 5.2.1 实现搜索功能 **目标**: - 按名称搜索图层 - 按类型过滤 - 显示/隐藏空图层 **实施要点**: ```kotlin @Composable fun LayerPanel( editorController: EditorController, state: ApplicationState, modifier: Modifier = Modifier ) { var searchQuery by remember { mutableStateOf("") } var filterType by remember { mutableStateOf(null) } val filteredLayers = remember(layers, searchQuery, filterType) { layers.filter { layer -> (searchQuery.isEmpty() || layer.name.contains(searchQuery, ignoreCase = true)) && (filterType == null || layer.type == filterType) } } // UI 实现 } ``` **预计工作量**: 3-5 天 --- ### 5.3 批量操作 #### 5.3.1 实现多选功能 **目标**: - 支持多选图层 - 批量锁定/解锁 - 批量重命名 **实施要点**: ```kotlin class LayerManager { private val _selectedLayers = MutableStateFlow>(emptySet()) val selectedLayers: StateFlow> = _selectedLayers.asStateFlow() fun selectLayer(layerId: UUID, multiSelect: Boolean = false) { if (multiSelect) { _selectedLayers.value = _selectedLayers.value.toMutableSet().apply { if (contains(layerId)) remove(layerId) else add(layerId) } } else { _selectedLayers.value = setOf(layerId) } } fun batchLock(locked: Boolean) { _selectedLayers.value.forEach { id -> setLayerLocked(id, locked) } } } ``` **预计工作量**: 7-10 天 --- ## 六、实施计划 ### 6.1 短期计划(1-2 周) **优先级**: ⭐⭐⭐ 最高 1. **图层删除功能**(2-3 天) - 添加删除按钮 - 实现背景层保护 - 添加确认对话框 2. **拖拽排序**(3-5 天) - 实现拖拽交互 - 更新图层顺序 3. **错误处理完善**(2-3 天) - 完善异常处理 - 改进用户提示 **总工作量**: 7-11 天 --- ### 6.2 中期计划(1-2 月) **优先级**: ⭐⭐ 高 1. **图像层旋转/缩放交互**(5-7 天) - 添加控制点 - 实现交互逻辑 2. **渲染性能优化**(5-7 天) - 实现渲染缓存 - 优化重绘逻辑 3. **撤销/重做功能**(10-15 天) - 实现命令模式 - 添加撤销/重做 UI 4. **快捷键支持**(5-7 天) - 实现常用快捷键 - 添加快捷键提示 **总工作量**: 25-36 天 --- ### 6.3 长期计划(3-6 月) **优先级**: ⭐ 中 1. **混合模式支持**(15-20 天) - 实现各种混合模式 - 添加 UI 选择器 2. **图层组功能**(20-30 天) - 实现分组逻辑 - 添加分组 UI 3. **调整层和滤镜层**(35-50 天) - 实现调整层 - 实现滤镜层 - 添加效果预览 4. **测试覆盖扩展**(10-15 天) - 扩展单元测试 - 添加集成测试 **总工作量**: 80-115 天 --- ## 七、技术债务清理 ### 7.1 代码清理 #### 7.1.1 移除未使用的代码 - 检查是否有废弃的 API - 清理注释掉的代码 - 移除未使用的导入 **预计工作量**: 1-2 天 --- #### 7.1.2 文档完善 - 添加 API 文档(KDoc) - 创建使用示例 - 编写架构决策记录(ADR) **预计工作量**: 3-5 天 --- #### 7.1.3 代码规范 - 统一命名规范 - 代码格式化 - 添加必要的注释 **预计工作量**: 2-3 天 --- ### 7.2 依赖管理 #### 7.2.1 依赖更新 - 定期更新依赖版本 - 评估新版本的功能和性能改进 - 处理废弃的 API **预计工作量**: 持续进行 --- ## 八、监控与评估 ### 8.1 性能监控 #### 8.1.1 关键指标 - **渲染帧率**: 目标 60 FPS - **内存使用**: 监控峰值内存 - **导出耗时**: 目标 < 2 秒(普通图像) **实施要点**: ```kotlin // 添加性能监控 class PerformanceMonitor { fun measureRenderTime(block: () -> Unit): Long { val start = System.currentTimeMillis() block() return System.currentTimeMillis() - start } fun logMemoryUsage() { val runtime = Runtime.getRuntime() val used = runtime.totalMemory() - runtime.freeMemory() logger.info("内存使用: ${used / 1024 / 1024} MB") } } ``` --- ### 8.2 用户反馈 #### 8.2.1 反馈收集 - 收集使用痛点 - 功能需求优先级 - Bug 报告 **实施要点**: - 在应用中添加反馈入口 - 定期收集用户意见 - 建立需求优先级评估机制 --- ### 8.3 代码质量指标 #### 8.3.1 质量指标 - **测试覆盖率**: 目标 80% 以上 - **代码复杂度**: 使用工具分析(如 SonarQube) - **技术债务**: 定期评估和清理 **实施要点**: - 使用代码质量工具 - 定期代码审查 - 技术债务跟踪 --- ## 九、风险评估 ### 9.1 技术风险 1. **性能风险** - 大量图层可能导致性能下降 - **缓解措施**: 实现渲染缓存和增量渲染 2. **兼容性风险** - 新功能可能影响现有功能 - **缓解措施**: 充分测试,渐进式发布 3. **复杂度风险** - 功能增加可能导致代码复杂度上升 - **缓解措施**: 代码重构,模块化设计 --- ### 9.2 时间风险 1. **估算不准确** - 实际工作量可能超过估算 - **缓解措施**: 预留缓冲时间,分阶段实施 2. **优先级冲突** - 多个高优先级任务可能冲突 - **缓解措施**: 明确优先级,合理分配资源 --- ## 十、总结 本路线图提供了图层系统优化的全面规划,涵盖了功能扩展、性能优化、代码质量提升、架构优化、用户体验改进等多个方面。 **建议实施顺序**: 1. 先完成短期计划(1-2 周),快速提升用户体验 2. 然后进行中期计划(1-2 月),优化性能和添加核心功能 3. 最后推进长期计划(3-6 月),实现高级功能和架构优化 **关键成功因素**: - 持续的用户反馈收集 - 定期的性能监控和优化 - 代码质量保证(测试、文档、规范) - 渐进式实施,避免大范围重构 --- **文档维护**: 本文档应随着项目进展定期更新,记录实际完成情况、遇到的问题和调整的计划。 ================================================ FILE: domain/build.gradle.kts ================================================ plugins { kotlin("jvm") } repositories { mavenCentral() } dependencies { testImplementation(kotlin("test")) implementation ("org.jetbrains.kotlin:kotlin-stdlib") } tasks.test { useJUnitPlatform() } kotlin { jvmToolchain(17) } ================================================ FILE: domain/src/main/kotlin/cn/netdiscovery/monica/domain/ColorCorrectionSettings.kt ================================================ package cn.netdiscovery.monica.domain /** * * @FileName: * cn.netdiscovery.monica.domain.ColorCorrectionSettings * @author: Tony Shen * @date: 2024/11/6 10:40 * @version: V1.0 <描述当前版本功能> */ data class ColorCorrectionSettings( val contrast:Int = 255, // 对比度,范围 0-510 val hue:Int = 180, // 色调,范围 0-360 val saturation:Int = 255, // 饱和度,范围 0-510 val lightness:Int = 255, // 亮度,范围 0-510 val temperature:Int = 255, // 色温,范围 0-510 val highlight:Int = 255, // 高光,范围 0-510 val shadow:Int = 255, // 阴影,范围 0-510 val sharpen:Int = 0, // 锐化,范围 0-255 val corner:Int = 0, // 暗角,范围 0-255 val status:Int = 0 // 1 contrast, 2 hue, 3 saturation, 4 lightness, 5 temperature, 6 highlight, 7 shadow, 8 sharpen, 9 corner ) ================================================ FILE: domain/src/main/kotlin/cn/netdiscovery/monica/domain/ContourDisplaySettings.kt ================================================ package cn.netdiscovery.monica.domain /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.model.ContourDisplaySettings * @author: Tony Shen * @date: 2024/10/29 14:26 * @version: V1.0 <描述当前版本功能> */ data class ContourDisplaySettings( var showOriginalImage: Boolean = false, var showBoundingRect: Boolean = false, var showMinAreaRect: Boolean = false, var showCenter: Boolean = false ) ================================================ FILE: domain/src/main/kotlin/cn/netdiscovery/monica/domain/ContourFilterSettings.kt ================================================ package cn.netdiscovery.monica.domain /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.model.ContourFilterSettings * @author: Tony Shen * @date: 2024/10/29 17:52 * @version: V1.0 <描述当前版本功能> */ data class ContourFilterSettings ( var minPerimeter:Double = 0.0, var maxPerimeter:Double = 0.0, var minArea:Double = 0.0, var maxArea:Double = 0.0, var minRoundness:Double = 0.0, var maxRoundness:Double = 0.0, var minAspectRatio:Double = 0.0, var maxAspectRatio:Double = 0.0 ) ================================================ FILE: domain/src/main/kotlin/cn/netdiscovery/monica/domain/DecodedPreviewImage.kt ================================================ package cn.netdiscovery.monica.domain /** * * @FileName: * cn.netdiscovery.monica.domain.DecodedPreviewImage * @author: Tony Shen * @date: 2025/7/21 12:40 * @version: V1.0 <描述当前版本功能> */ data class DecodedPreviewImage( val nativePtr: Long, // 对应 MonicaImageProcess 中 PyramidImage 对象的指针地址 val width: Int, val height: Int, val previewImage: IntArray // 返回金字塔第一层的图像 ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as DecodedPreviewImage if (nativePtr != other.nativePtr) return false if (width != other.width) return false if (height != other.height) return false if (!previewImage.contentEquals(other.previewImage)) return false return true } override fun hashCode(): Int { var result = nativePtr.hashCode() result = 31 * result + width result = 31 * result + height result = 31 * result + previewImage.contentHashCode() return result } } ================================================ FILE: domain/src/main/kotlin/cn/netdiscovery/monica/domain/GeneralSettings.kt ================================================ package cn.netdiscovery.monica.domain /** * * @FileName: * cn.netdiscovery.monica.domain.GeneralSettings * @author: Tony Shen * @date: 2025/2/7 10:27 * @version: V1.0 <描述当前版本功能> */ data class GeneralSettings( var outputBoxR: Int, var outputBoxG: Int, var outputBoxB: Int, var size: Int, var maxHistorySize: Int, var deepSeekApiKey: String, var geminiApiKey: String, var algorithmUrl: String, var themeId: String = "LIGHT" ) ================================================ FILE: domain/src/main/kotlin/cn/netdiscovery/monica/domain/MatchTemplateSettings.kt ================================================ package cn.netdiscovery.monica.domain /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.model.MatchTemplateSettings * @author: Tony Shen * @date: 2025/1/4 20:29 * @version: V1.0 <描述当前版本功能> */ data class MatchTemplateSettings ( var matchType:Int = 0, // 0 表示原图匹配,1 表示灰度匹配 2 表示边缘匹配 var angleStart:Int = 0, var angleEnd:Int = 360, var angleStep:Int = 10, var scaleStart:Double = 0.0, var scaleEnd:Double = 1.0, var scaleStep:Double = 0.1, var matchTemplateThreshold:Double = 0.8, // 模版匹配的阈值 var scoreThreshold: Float = 0.6f , // 置信分数的阈值(nms 相关) var nmsThreshold: Float = 0.3f // 非极大值抑制的阈值(nms 相关) ) ================================================ FILE: domain/src/main/kotlin/cn/netdiscovery/monica/domain/MorphologicalOperationSettings.kt ================================================ package cn.netdiscovery.monica.domain /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.model.MorphologicalOperationSettings * @author: Tony Shen * @date: 2024/12/26 20:42 * @version: V1.0 <描述当前版本功能> */ data class MorphologicalOperationSettings( var op:Int = 0, var shape:Int = 0, var width:Int = 0, var height:Int = 0 ) ================================================ FILE: domain/src/main/kotlin/cn/netdiscovery/monica/domain/NativeImage.kt ================================================ package cn.netdiscovery.monica.domain /** * * @FileName: * cn.netdiscovery.monica.domain.NativeImage * @author: Tony Shen * @date: 2025/7/22 14:20 * @version: V1.0 <描述当前版本功能> */ data class NativeImage( val width: Int, val height: Int, val pixels: IntArray ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as NativeImage if (width != other.width) return false if (height != other.height) return false if (!pixels.contentEquals(other.pixels)) return false return true } override fun hashCode(): Int { var result = width result = 31 * result + height result = 31 * result + pixels.contentHashCode() return result } } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ kotlin.code.style=official app.version=1.1.5 kotlin.version=2.1.0 agp.version=7.3.0 compose.version=1.6.11 kotlinx.coroutines.core.version=1.8.1-Beta koin.compose=4.0.0 logback=1.2.3 colormath=3.5.0 twelvemonkeys=3.12.0 batik=1.19 rxcache=2.2.0 coroutines.utils=v1.1.8 ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in # double quotes to make sure that they get re-expanded; and # * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: i18n/QUICK_REFERENCE.md ================================================ # i18n 脚本快速参考 ## 🚀 快速开始 ### 基本检查 ```bash # 检查中英文文件是否同步 ./string_manager.sh -m # 检查重复项(不修改文件) ./string_manager.sh -c -d # 检查行号一致性 ./position_check.sh ``` ### 修复重复项 ```bash # 自动修复重复项 ./string_manager.sh -c -f ``` ## 📋 常用命令 | 命令 | 功能 | |------|------| | `./string_manager.sh --help` | 显示帮助信息 | | `./string_manager.sh -m` | 比对中英文文件差异 | | `./string_manager.sh -c -d` | 检查重复项(不修改) | | `./string_manager.sh -c -f` | 自动修复重复项 | | `./string_manager.sh -c -v -d` | 详细检查重复项 | | `./string_manager.sh -a -f` | 全功能模式 | | `./position_check.sh` | 检查行号一致性 | ## ⚡ 一键检查脚本 创建一个检查脚本 `quick_check.sh`: ```bash #!/bin/bash echo "=== i18n 文件快速检查 ===" echo "" echo "1. 检查中英文文件同步性..." ./string_manager.sh -m echo "" echo "2. 检查重复项..." ./string_manager.sh -c -d echo "" echo "3. 检查行号一致性..." ./position_check.sh echo "" echo "=== 检查完成 ===" ``` 使用方法: ```bash chmod +x quick_check.sh ./quick_check.sh ``` ## 🔧 故障排除 ### 权限问题 ```bash chmod +x string_manager.sh position_check.sh ``` ### 文件不存在 ```bash ls -la src/main/resources/strings/ ``` ### 语法检查 ```bash bash -n string_manager.sh bash -n position_check.sh ``` ## 📊 输出解读 ### ✅ 正常状态 - 中英文文件字符串数量相同 - 无缺失翻译 - 无重复字符串名称 ### ⚠️ 需要注意 - 有重复字符串内容(正常,不同key可以有相同内容) - 行号不一致(正常,文件结构可能不同) ### ❌ 需要修复 - 有重复字符串名称 - 有缺失的翻译 - 文件不同步 ================================================ FILE: i18n/README.md ================================================ # 🌍 Monica 国际化工具集 ## 📋 概述 Monica 项目的国际化字符串资源管理工具集,提供完整的字符串资源文件管理解决方案。 ## 🛠️ 工具列表 ### 核心工具 - **`string_manager.sh`** - 综合管理工具 - 清理重复项 - 检查缺失翻译 - 文件统计报告 - 自动修复功能 - **`position_check.sh`** - 位置比对工具 - 检查中英文文件位置一致性 - 详细的位置差异分析 - 统计信息报告 ### 文档 - **`SCRIPT_USAGE_GUIDE.md`** - 脚本使用指南 - 详细的使用说明和示例 - 所有选项和参数说明 - 输出解读和故障排除 - **`QUICK_REFERENCE.md`** - 快速参考 - 常用命令速查 - 一键检查脚本 - 快速故障排除 - **`I18N_STRING_MANAGEMENT_GUIDE.md`** - 完整使用指南 - 详细的使用说明 - 工作流程指导 - 最佳实践建议 - 故障排除指南 ## 🚀 快速开始 ```bash # 进入工具目录 cd i18n # 一键检查所有问题 ./quick_check.sh # 或者单独使用各个工具 ./string_manager.sh -m # 检查同步性 ./string_manager.sh -c -d # 检查重复项 ./position_check.sh # 检查行号一致性 ``` ## 📖 详细文档 - **`SCRIPT_USAGE_GUIDE.md`** - 脚本使用详细指南 - **`QUICK_REFERENCE.md`** - 快速参考和常用命令 - **`I18N_STRING_MANAGEMENT_GUIDE.md`** - 完整的国际化管理指南 ## 🎯 主要功能 - ✅ 自动检测重复字符串 - ✅ 比对中英文翻译完整性 - ✅ 检查文件位置一致性 - ✅ 生成详细统计报告 - ✅ 自动备份和修复 - ✅ 支持批量处理 ## 📊 当前状态 - **字符串总数**: 366个 - **文件状态**: 完整同步 - **重复项**: 无重复字符串名称 - **位置一致性**: 349个key位置不一致(正常现象) ## 🔧 维护建议 1. **日常**: 使用 `./quick_check.sh` 快速检查 2. **开发新功能后**: 运行 `./string_manager.sh -m` 检查同步性 3. **发现重复项**: 使用 `./string_manager.sh -c -f` 自动修复 4. **定期维护**: 每月运行完整检查 --- **版本**: 2.0 **最后更新**: 2025-09-03 **维护者**: AI Assistant ================================================ FILE: i18n/SCRIPT_USAGE_GUIDE.md ================================================ # i18n 脚本使用指南 ## 📖 概述 本指南介绍 i18n 模块中两个字符串资源管理脚本的使用方法: - `string_manager.sh` - 字符串资源文件综合管理工具 - `position_check.sh` - 位置比对脚本 ## 🔧 string_manager.sh - 字符串资源文件综合管理工具 ### 功能特性 - ✅ 检查重复的字符串名称和内容 - ✅ 比对中英文文件差异 - ✅ 自动修复重复项 - ✅ 位置比对模式 - ✅ 详细输出和干运行模式 - ✅ 自动备份功能 ### 基本语法 ```bash ./string_manager.sh [选项] <文件路径> ``` ### 模式选项 | 选项 | 长选项 | 功能 | |------|--------|------| | `-c` | `--cleanup` | 清理模式:检查并清理重复项 | | `-m` | `--compare` | 比对模式:比对中英文文件差异 | | `-p` | `--position` | 位置比对模式:检查相同key的行号是否一致 | | `-a` | `--all` | 全功能模式:清理 + 比对 | ### 清理模式选项 | 选项 | 长选项 | 功能 | |------|--------|------| | `-v` | `--verbose` | 详细输出 | | `-d` | `--dry-run` | 只检查,不修改文件 | | `-n` | `--no-backup` | 不创建备份文件 | | `-f` | `--auto-fix` | 自动修复重复项(保留第一次出现的版本) | ### 使用示例 #### 1. 查看帮助信息 ```bash ./string_manager.sh --help ``` #### 2. 比对中英文文件差异 ```bash # 检查中英文文件是否同步 ./string_manager.sh -m ``` **输出示例:** ``` === 中英文字符串资源文件比对 === 1. 提取中文文件中的字符串名称... 2. 提取英文文件中的字符串名称... 3. 统计信息... 中文文件字符串数量: 366 英文文件字符串数量: 366 4. 中文有但英文没有的字符串: ✅ 没有缺失的英文翻译 5. 英文有但中文没有的字符串: ✅ 没有缺失的中文翻译 === 比对完成 === ``` #### 3. 检查重复项(不修改文件) ```bash # 干运行模式,只检查不修改 ./string_manager.sh -c -d ``` **输出示例:** ``` 字符串资源文件清理工具 v2.0 处理文件: src/main/resources/strings/strings_zh.xml ================================== === 文件统计报告 === 文件: src/main/resources/strings/strings_zh.xml 总行数: 470 字符串总数: 366 唯一字符串名称: 366 重复字符串名称: 0 ✓ 没有发现重复的字符串名称 发现 31 个重复的字符串内容: 人脸替换 人脸检测 伽马变换 ... 处理完成! ``` #### 4. 自动修复重复项 ```bash # 自动修复重复项,保留第一次出现的版本 ./string_manager.sh -c -f ``` #### 5. 详细检查模式 ```bash # 详细输出,显示重复项的详细信息 ./string_manager.sh -c -v -d ``` #### 6. 全功能模式 ```bash # 清理 + 比对,自动修复 ./string_manager.sh -a -f ``` #### 7. 检查特定文件 ```bash # 检查指定的文件 ./string_manager.sh -c src/main/resources/strings/strings_zh.xml ``` ### 输出说明 #### 文件统计报告 - **总行数**: 文件的总行数 - **字符串总数**: 包含的字符串定义数量 - **唯一字符串名称**: 不重复的字符串名称数量 - **重复字符串名称**: 重复的字符串名称数量 #### 重复项检测 - **重复字符串名称**: 相同 `name` 属性的字符串 - **重复字符串内容**: 相同内容的字符串(可能名称不同) ## 🔍 position_check.sh - 位置比对脚本 ### 功能特性 - ✅ 检查中英文配置文件中相同key的行号是否一致 - ✅ 统计行号差异信息 - ✅ 提供详细的差异报告 ### 基本语法 ```bash ./position_check.sh ``` ### 使用示例 #### 检查行号一致性 ```bash ./position_check.sh ``` **输出示例:** ``` === 中英文字符串资源文件位置比对 === 1. 提取中文文件中的key和行号... 2. 提取英文文件中的key和行号... 3. 统计信息... 中文文件字符串数量: 366 英文文件字符串数量: 366 4. 共同key数量: 366 5. 检查行号一致性... 行号不匹配: adaptive_threshold_algorithm 中文文件第288行 英文文件第435行 差异: -147 行 行号不匹配: adaptive_threshold_cancelled 中文文件第446行 英文文件第421行 差异: 25 行 ... 6. 位置比对结果: ⚠️ 发现 349 个key的行号不一致 最大行号差异: 443 行 平均行号差异: 53.3 行 === 位置比对完成 === ``` ### 输出说明 #### 统计信息 - **中文文件字符串数量**: 中文文件中的字符串总数 - **英文文件字符串数量**: 英文文件中的字符串总数 - **共同key数量**: 两个文件都包含的字符串数量 #### 行号差异 - **行号不匹配**: 相同key在不同文件中的行号不同 - **差异**: 行号差值(正数表示中文文件行号更大,负数表示英文文件行号更大) #### 比对结果 - **不匹配数量**: 行号不一致的key数量 - **最大行号差异**: 最大的行号差值 - **平均行号差异**: 平均的行号差值 ## 🚀 常见使用场景 ### 场景1:日常维护检查 ```bash # 检查文件是否同步 ./string_manager.sh -m # 检查是否有重复项 ./string_manager.sh -c -d ``` ### 场景2:修复重复项 ```bash # 先检查重复项 ./string_manager.sh -c -v -d # 确认后自动修复 ./string_manager.sh -c -f ``` ### 场景3:全面检查 ```bash # 全功能检查 ./string_manager.sh -a -f # 检查行号一致性 ./position_check.sh ``` ### 场景4:开发新功能后 ```bash # 添加新字符串后,检查同步性 ./string_manager.sh -m # 检查是否有重复 ./string_manager.sh -c -d ``` ## ⚠️ 注意事项 ### 备份建议 - 使用 `-f` 选项自动修复前,建议先运行 `-d` 选项检查 - 脚本会自动创建备份文件(除非使用 `-n` 选项) - 备份文件格式:`原文件名.backup.时间戳` ### 文件路径 - 脚本默认使用 `src/main/resources/strings/` 目录下的文件 - 可以指定其他文件路径作为参数 - 确保文件路径正确且文件存在 ### 权限要求 - 脚本需要执行权限:`chmod +x string_manager.sh position_check.sh` - 修改文件需要写入权限 ## 🔧 故障排除 ### 常见错误 #### 1. 权限错误 ```bash # 解决方案:添加执行权限 chmod +x string_manager.sh position_check.sh ``` #### 2. 文件不存在 ```bash # 检查文件路径 ls -la src/main/resources/strings/ ``` #### 3. 语法错误 ```bash # 检查脚本语法 bash -n string_manager.sh bash -n position_check.sh ``` ### 调试技巧 #### 1. 详细输出 ```bash # 使用 -v 选项查看详细信息 ./string_manager.sh -c -v -d ``` #### 2. 干运行模式 ```bash # 使用 -d 选项不修改文件 ./string_manager.sh -c -d ``` #### 3. 检查特定文件 ```bash # 指定文件路径 ./string_manager.sh -c /path/to/your/file.xml ``` ## 📚 相关文档 - [i18n 模块 README](README.md) - [字符串管理指南](I18N_STRING_MANAGEMENT_GUIDE.md) - [国际化测试文档](src/test/kotlin/cn/netdiscovery/monica/i18n/InternationalizationTest.kt) ## 🤝 贡献指南 如果你发现脚本的问题或有改进建议,请: 1. 检查脚本语法:`bash -n script_name.sh` 2. 测试功能:使用 `-d` 选项进行干运行 3. 提交问题或改进建议 --- **最后更新**: 2024年12月 **版本**: 2.0 **维护者**: AI Assistant ================================================ FILE: i18n/build.gradle.kts ================================================ plugins { kotlin("jvm") } repositories { mavenCentral() } java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } dependencies { testImplementation(kotlin("test")) testImplementation("junit:junit:4.13.2") implementation ("org.jetbrains.kotlin:kotlin-stdlib") // log config implementation("ch.qos.logback:logback-classic:${rootProject.extra["logback"]}") implementation("ch.qos.logback:logback-core:${rootProject.extra["logback"]}") implementation("ch.qos.logback:logback-access:${rootProject.extra["logback"]}") // Config module implementation(project(":config")) } tasks.test { useJUnitPlatform() } ================================================ FILE: i18n/position_check.sh ================================================ #!/bin/bash # 位置比对脚本 # 检查中英文配置文件中相同key的行号是否一致 # 颜色定义 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # 文件路径 ZH_FILE="src/main/resources/strings/strings_zh.xml" EN_FILE="src/main/resources/strings/strings_en.xml" echo "=== 中英文字符串资源文件位置比对 ===" echo "" # 检查文件是否存在 if [[ ! -f "$ZH_FILE" ]]; then echo -e "${RED}错误: 中文文件不存在: $ZH_FILE${NC}" exit 1 fi if [[ ! -f "$EN_FILE" ]]; then echo -e "${RED}错误: 英文文件不存在: $EN_FILE${NC}" exit 1 fi # 创建临时文件存储key和行号的映射 ZH_TEMP=$(mktemp) EN_TEMP=$(mktemp) echo "1. 提取中文文件中的key和行号..." grep -n 'name="[^"]*"' "$ZH_FILE" | sed 's/^\([0-9]*\):.*name="\([^"]*\)".*/\1:\2/' > "$ZH_TEMP" echo "2. 提取英文文件中的key和行号..." grep -n 'name="[^"]*"' "$EN_FILE" | sed 's/^\([0-9]*\):.*name="\([^"]*\)".*/\1:\2/' > "$EN_TEMP" echo "3. 统计信息..." ZH_COUNT=$(wc -l < "$ZH_TEMP") EN_COUNT=$(wc -l < "$EN_TEMP") echo "中文文件字符串数量: $ZH_COUNT" echo "英文文件字符串数量: $EN_COUNT" echo "" # 找出共同的key COMMON_KEYS=$(comm -12 <(cut -d: -f2 "$ZH_TEMP" | sort) <(cut -d: -f2 "$EN_TEMP" | sort)) COMMON_COUNT=$(echo "$COMMON_KEYS" | wc -l) echo "4. 共同key数量: $COMMON_COUNT" echo "" # 检查行号差异 echo "5. 检查行号一致性..." MISMATCHED_COUNT=0 TOTAL_DIFF=0 MAX_DIFF=0 echo "$COMMON_KEYS" | while read key; do ZH_LINE=$(grep ":$key$" "$ZH_TEMP" | cut -d: -f1) EN_LINE=$(grep ":$key$" "$EN_TEMP" | cut -d: -f1) if [[ -n "$ZH_LINE" && -n "$EN_LINE" ]]; then DIFF=$((ZH_LINE - EN_LINE)) # 取绝对值 if [[ $DIFF -lt 0 ]]; then ABS_DIFF=$((-DIFF)) else ABS_DIFF=$DIFF fi if [[ $ABS_DIFF -gt 0 ]]; then echo -e "${YELLOW}行号不匹配: $key${NC}" echo " 中文文件第${ZH_LINE}行" echo " 英文文件第${EN_LINE}行" echo " 差异: $DIFF 行" echo "" fi fi done # 统计不匹配的数量 ACTUAL_MISMATCHED=$(echo "$COMMON_KEYS" | while read key; do ZH_LINE=$(grep ":$key$" "$ZH_TEMP" | cut -d: -f1) EN_LINE=$(grep ":$key$" "$EN_TEMP" | cut -d: -f1) if [[ -n "$ZH_LINE" && -n "$EN_LINE" ]]; then DIFF=$((ZH_LINE - EN_LINE)) # 取绝对值 if [[ $DIFF -lt 0 ]]; then ABS_DIFF=$((-DIFF)) else ABS_DIFF=$DIFF fi if [[ $ABS_DIFF -gt 0 ]]; then echo "1" fi fi done | wc -l) # 计算总差异 ACTUAL_TOTAL_DIFF=$(echo "$COMMON_KEYS" | while read key; do ZH_LINE=$(grep ":$key$" "$ZH_TEMP" | cut -d: -f1) EN_LINE=$(grep ":$key$" "$EN_TEMP" | cut -d: -f1) if [[ -n "$ZH_LINE" && -n "$EN_LINE" ]]; then DIFF=$((ZH_LINE - EN_LINE)) # 取绝对值 if [[ $DIFF -lt 0 ]]; then ABS_DIFF=$((-DIFF)) else ABS_DIFF=$DIFF fi echo "$ABS_DIFF" fi done | awk '{sum+=$1} END {print sum}') # 计算最大差异 ACTUAL_MAX_DIFF=$(echo "$COMMON_KEYS" | while read key; do ZH_LINE=$(grep ":$key$" "$ZH_TEMP" | cut -d: -f1) EN_LINE=$(grep ":$key$" "$EN_TEMP" | cut -d: -f1) if [[ -n "$ZH_LINE" && -n "$EN_LINE" ]]; then DIFF=$((ZH_LINE - EN_LINE)) # 取绝对值 if [[ $DIFF -lt 0 ]]; then ABS_DIFF=$((-DIFF)) else ABS_DIFF=$DIFF fi echo "$ABS_DIFF" fi done | sort -n | tail -1) echo "6. 位置比对结果:" if [[ $ACTUAL_MISMATCHED -eq 0 ]]; then echo -e "${GREEN}✅ 所有共同key的行号都一致!${NC}" else echo -e "${YELLOW}⚠️ 发现 $ACTUAL_MISMATCHED 个key的行号不一致${NC}" echo " 最大行号差异: $ACTUAL_MAX_DIFF 行" if [[ $ACTUAL_MISMATCHED -gt 0 ]]; then AVERAGE_DIFF=$(echo "scale=1; $ACTUAL_TOTAL_DIFF / $ACTUAL_MISMATCHED" | bc 2>/dev/null || echo "N/A") echo " 平均行号差异: $AVERAGE_DIFF 行" fi fi # 清理临时文件 rm "$ZH_TEMP" "$EN_TEMP" echo "" echo "=== 位置比对完成 ===" ================================================ FILE: i18n/quick_check.sh ================================================ #!/bin/bash # i18n 文件快速检查脚本 # 功能:一键检查中英文文件同步性、重复项和行号一致性 # 颜色定义 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color echo -e "${BLUE}=== i18n 文件快速检查 ===${NC}" echo "" # 检查脚本是否存在 if [[ ! -f "string_manager.sh" ]]; then echo -e "${RED}错误: string_manager.sh 不存在${NC}" exit 1 fi if [[ ! -f "position_check.sh" ]]; then echo -e "${RED}错误: position_check.sh 不存在${NC}" exit 1 fi # 检查脚本权限 if [[ ! -x "string_manager.sh" ]]; then echo -e "${YELLOW}警告: string_manager.sh 没有执行权限,正在修复...${NC}" chmod +x string_manager.sh fi if [[ ! -x "position_check.sh" ]]; then echo -e "${YELLOW}警告: position_check.sh 没有执行权限,正在修复...${NC}" chmod +x position_check.sh fi echo -e "${BLUE}1. 检查中英文文件同步性...${NC}" echo "----------------------------------------" ./string_manager.sh -m echo "" echo -e "${BLUE}2. 检查重复项...${NC}" echo "----------------------------------------" ./string_manager.sh -c -d echo "" echo -e "${BLUE}3. 检查行号一致性...${NC}" echo "----------------------------------------" ./position_check.sh echo "" echo -e "${GREEN}=== 检查完成 ===${NC}" echo "" echo -e "${YELLOW}💡 提示:${NC}" echo " - 如果发现重复项,使用: ./string_manager.sh -c -f" echo " - 如果发现缺失翻译,请手动添加" echo " - 行号不一致是正常的,不影响功能" echo "" echo -e "${BLUE}📚 更多信息请查看: SCRIPT_USAGE_GUIDE.md${NC}" ================================================ FILE: i18n/src/main/kotlin/cn/netdiscovery/monica/i18n/Language.kt ================================================ package cn.netdiscovery.monica.i18n /** * 支持的语言枚举 */ enum class Language(val code: String, val displayName: String, val flag: String) { CHINESE("zh", "中文", "🇨🇳"), ENGLISH("en", "English", "🇺🇸"); companion object { fun fromCode(code: String): Language { return values().find { it.code == code } ?: CHINESE } fun getSystemLanguage(): Language { val systemLang = java.util.Locale.getDefault().language return when (systemLang) { "zh" -> CHINESE "en" -> ENGLISH else -> CHINESE // 默认中文,因为项目主要面向中文用户 } } } } ================================================ FILE: i18n/src/main/kotlin/cn/netdiscovery/monica/i18n/LocalizationManager.kt ================================================ package cn.netdiscovery.monica.i18n import cn.netdiscovery.monica.config.category.ConfigCategoryManager /** * 国际化管理器 * * 负责管理应用的语言设置和本地化资源 */ object LocalizationManager { private const val LANGUAGE_KEY = "selected_language" // 当前语言状态 private var _currentLanguage = getSavedLanguage() val currentLanguage: Language get() = _currentLanguage // 语言变化监听器列表 private val languageChangeListeners = mutableListOf<() -> Unit>() /** * 添加语言变化监听器 */ fun addLanguageChangeListener(listener: () -> Unit) { languageChangeListeners.add(listener) } /** * 移除语言变化监听器 */ fun removeLanguageChangeListener(listener: () -> Unit) { languageChangeListeners.remove(listener) } /** * 获取保存的语言设置 */ private fun getSavedLanguage(): Language { val savedCode: String? = ConfigCategoryManager.load(LANGUAGE_KEY, null as String?) return if (savedCode != null) { Language.fromCode(savedCode) } else { Language.getSystemLanguage() } } /** * 设置当前语言 */ fun setLanguage(language: Language) { if (_currentLanguage != language) { _currentLanguage = language ConfigCategoryManager.save(LANGUAGE_KEY, language.code) // 清除缓存,强制重新加载资源 clearCache() // 通知所有监听器语言已变化 languageChangeListeners.forEach { it.invoke() } } } /** * 清除资源缓存 */ private fun clearCache() { chineseXmlResource = null englishXmlResource = null } // XML资源缓存 private var chineseXmlResource: XmlBasedStringResource? = null private var englishXmlResource: XmlBasedStringResource? = null /** * 获取XML字符串资源 */ fun getXmlResource(language: Language): XmlBasedStringResource { return when (language) { Language.CHINESE -> { if (chineseXmlResource == null) { chineseXmlResource = XmlBasedStringResource(Language.CHINESE) } chineseXmlResource ?: throw IllegalStateException("中文资源文件未加载") } Language.ENGLISH -> { if (englishXmlResource == null) { englishXmlResource = XmlBasedStringResource(Language.ENGLISH) } englishXmlResource ?: throw IllegalStateException("英文资源文件未加载") } } } /** * 获取当前语言的字符串资源 */ fun getString(key: String): String { val xmlResource = getXmlResource(_currentLanguage) return xmlResource.get(key) } /** * 获取带参数的字符串资源 */ fun getString(key: String, vararg args: Any): String { val xmlResource = getXmlResource(_currentLanguage) return xmlResource.get(key, *args) } /** * 获取所有支持的语言 */ fun getSupportedLanguages(): List = Language.values().toList() /** * 获取当前语言代码 */ fun getCurrentLanguageCode(): String = _currentLanguage.code /** * 获取当前语言显示名称 */ fun getCurrentLanguageDisplayName(): String = _currentLanguage.displayName } /** * 获取当前语言的字符串资源 */ fun getCurrentStringResource(): StringResource { return StringResource(LocalizationManager.currentLanguage) } /** * 字符串资源访问器 */ class StringResource(private val language: Language) { private val xmlResource by lazy { LocalizationManager.getXmlResource(language) } fun get(key: String): String = LocalizationManager.getString(key) fun get(key: String, vararg args: Any): String = LocalizationManager.getString(key, *args) /** * 直接从XML资源获取字符串(用于测试和调试) */ fun getFromXml(key: String): String = xmlResource.get(key) /** * 检查XML资源中是否包含指定key */ fun containsInXml(key: String): Boolean = xmlResource.contains(key) /** * 获取XML资源信息 */ fun getXmlResourceInfo(): String = xmlResource.getResourceInfo() /** * 获取所有可用的键 */ fun getAllKeys(): Set = xmlResource.getAllKeys() } ================================================ FILE: i18n/src/main/kotlin/cn/netdiscovery/monica/i18n/XmlStringResource.kt ================================================ package cn.netdiscovery.monica.i18n import org.w3c.dom.Document import org.w3c.dom.Element import java.io.InputStream import org.slf4j.LoggerFactory import javax.xml.parsers.DocumentBuilderFactory /** * XML格式的字符串资源加载器 * * 支持从XML文件加载国际化字符串资源 */ object XmlStringResource { private val logger = LoggerFactory.getLogger(XmlStringResource::class.java.name) /** * 从XML文件加载字符串资源 */ fun loadStrings(resourcePath: String): Map { return try { val inputStream: InputStream? = this::class.java.classLoader.getResourceAsStream(resourcePath) if (inputStream == null) { logger.warn("无法找到资源文件: $resourcePath") return emptyMap() } val documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder() val document: Document = documentBuilder.parse(inputStream) val stringMap = mutableMapOf() val stringNodes = document.getElementsByTagName("string") for (i in 0 until stringNodes.length) { val stringNode = stringNodes.item(i) as Element val name = stringNode.getAttribute("name") val value = stringNode.textContent if (name.isNotEmpty() && value.isNotEmpty()) { stringMap[name] = value } else { logger.warn("跳过无效的字符串资源: name='$name', value='$value'") } } logger.info("成功加载 ${stringMap.size} 个字符串资源从: $resourcePath") stringMap } catch (e: Exception) { logger.error("加载XML字符串资源失败: $resourcePath, 错误: ${e.message}") e.printStackTrace() emptyMap() } } /** * 获取指定语言的字符串资源 */ fun getStringsForLanguage(language: Language): Map { val resourcePath = when (language) { Language.CHINESE -> "strings/strings_zh.xml" Language.ENGLISH -> "strings/strings_en.xml" } return loadStrings(resourcePath) } } /** * 基于XML的字符串资源实现 */ class XmlBasedStringResource( private val language: Language ) { private val strings: Map = XmlStringResource.getStringsForLanguage(language) private val logger = LoggerFactory.getLogger(XmlBasedStringResource::class.java.name) fun get(key: String): String { val value = strings[key] if (value == null) { logger.warn("未找到字符串资源: $key (语言: ${language.name})") return "[$key]" // 返回带方括号的key作为fallback } return value } /** * 获取带参数替换的字符串 */ fun get(key: String, vararg args: Any): String { val template = get(key) return try { String.format(template, *args) } catch (e: Exception) { logger.warn("字符串格式化失败: $key, 模板: '$template', 参数: ${args.contentToString()}") template } } /** * 检查是否包含指定的key */ fun contains(key: String): Boolean { return strings.containsKey(key) } /** * 获取所有可用的keys */ fun getAllKeys(): Set { return strings.keys } /** * 获取资源统计信息 */ fun getResourceInfo(): String { return "语言: ${language.name}, 字符串数量: ${strings.size}" } } ================================================ FILE: i18n/src/main/resources/strings/strings_en.xml ================================================ Monica Monica is a cross-platform image editor Software Version Info Open Local Image Load Network Image Save Image Screenshot (Full Screen) Screenshot (Area Selection) Web Long Screenshot Exit Control Panel General Settings Basic Functions Color Correction Filter Effects AI Laboratory Settings Monica General Settings Update Close Image Blur Image Mosaic Image Doodle Shape Drawing Color Picker Crop Image Image Compression Original Compressed Input Selection Single Image Folder (Batch) Generate GIF Compression Mode Compression Algorithm Quality Setting (Visually Lossless) Higher values result in lower compression rates and larger files; lower values result in higher compression rates but may lose details Compression Level Higher levels result in higher compression rates but longer processing times; lower levels are faster to process Compress Image Select Input Folder Select Output Folder Start Batch Compression Compression Result Original Size Compressed Size Compression Ratio Increase Tip: The compressed file is larger than the original. Try lowering quality or choosing another algorithm (e.g., JPEG Quality for photos; PNG Optimization for screenshots/transparency). Selected Error: Please select an image first Compressing image... WebP format is not supported on this system, automatically converted to %s WebP encoding failed, automatically converted to %s Compression successful! Compression failed Compression error: %s Preparing batch compression... No images in folder Compression cancelled Compressing: %s (%d/%d) Cannot read image Batch compression completed (Success: %d/%d) Batch compression error: %s Please select an image to compress Select Image Please select or load an image first File Overwrite Confirmation File \"%s\" already exists. Overwrite? Save Compressed Image Please compress first Start Compression Please select an image in the preview area first Batch mode does not support preview comparison. Please check progress and summary. Reset Applied to editor Apply to Editor Save successful: %s Save failed Save Cannot read image file Failed to load image: %s Image cleared Clear image WebP is not supported on this system, will automatically convert to %s Batch Compression Warning Output folder already contains %d files. Batch compression may overwrite files with the same name. Continue? Cancel Undo Nothing to undo Undone Undone and reset Reset Note: Converting JPG to PNG may result in a larger file size, as PNG is a lossless format Note: Converting JPG to WebP Lossless may result in a larger file size Select Color Change Properties Line Circle Triangle Rectangle Polygon Add Text Save Brush Eraser Clear Natural Language Color Enter color instruction Parameters updated: %s Filter Parameters AI Laboratory Simple CV Algorithm Quick Validation Face Detection Generate Sketch Face Swap Anime Style Home Binary Image Edge Detection Contour Analysis Image Enhancement Image Denoising Morphological Operations Template Matching Parameter History Crop Type Content Scale Aspect Ratio Crop Frame Crop Properties Settings Confirm Dismiss Loading... Error Success Warning Info Alpha Font Size Fill Border Stroke Width Load Network Image Enter image URL Load Invalid URL format Basic Settings API Settings Theme Settings Language Settings Current Theme Select Theme Light Theme Dark Theme Blue Theme Green Theme Purple Theme Orange Theme Pink Theme Reset to Default Theme Version Info © 2024 Tony Shen. All rights reserved. Basic functions cancelled Basic functions selected General settings cancelled General settings selected Color correction cancelled Color correction selected Filter effects cancelled Filter effects selected AI laboratory cancelled AI laboratory selected Simple CV Algorithm Quick Validation Face Detection Generate Sketch Face Swap Anime Style Monica Software Information Monica Version: %s, %s, Build Time: %s OpenCV Version: %s, Local Algorithm Library: %s Copyright: Copyright 2024-Present, Tony Shen Github URL: https://github.com/fengzhizi715/Monica Output Box Color Settings: Area Size Settings (for blur/mosaic): Max History Size per Module: Algorithm Service URL: Initialize filter parameter configuration Clear all cache data Reset X Direction Y Direction Enter Filter Interface Please select a filter %s Filter Please select a filter first Image Editor: Filter Module Export Adjustments Reset Filter Apply Failed to apply filter Filter applied No image No filters found Parameter Summary Using default parameters %d adjusted Tip: click "Reset Filter" at the bottom to restore default parameters Clear Filter Autumn Bone Cool Warm HSV Jet Ocean Pink Rainbow Spring Summer Winter Atmosphere Burning Haze Frozen Lava Metal Ocean Flowing Water Notes: Search Fit Please select an anime style Please add images first Select images GIF Generation Strategy GIF Width GIF Height Frame Interval (ms) Loop Playback Natural Language Color Correction Gaussian Filter Median Filter Gaussian Bilateral Filter Mean Shift Filter Grayscale Image Threshold Segmentation Canny Edge Detection Color Image Segmentation Template Matching Method Rotation Min Angle Max Angle Angle Step Scale Min Scale Max Scale Scale Step Template Matching Parameters Threshold NMS Parameters Score Threshold NMS Threshold Filter Settings Min Value Max Value Min Value Max Value Min Value Max Value Min Value Max Value Display Settings Operation Element Structural Element Width Height Histogram Equalization Contrast Limited Adaptive Histogram Equalization (CLAHE) Gamma Transform Laplace Sharpening USM Sharpening Automatic Color Balance Edge Detection Operator Replace face count in target Click to select image Monica Image Editor Notification Export Image Min Value Max Value Restore Restore Original Previous Step Enlarge Preview Delete Image Color Correction Enter Image Color Correction Interface Image Flip Image Rotate Image Scale Image Shear Image Crop Miyazaki Style Japanese Portrait Style Black & White Line Art Shinkai Style Cute Style Image Grayscale Threshold Type Prewitt Operator Sobel Operator LoG Operator DoG Operator First Derivative Operator Second Derivative Operator First Derivative Edge Detection Second Derivative Edge Detection Canny Operator Canny Edge Detection Click to select image or drag image here Image saved successfully Image save failed Please select first derivative operator type Please select second derivative operator type Please select threshold type Please select global threshold segmentation type Please select adaptive threshold algorithm type Please select threshold type and global threshold segmentation or adaptive threshold segmentation ksize needs int type sigmaX needs double type sigmaY needs double type d needs int type sigmaColor needs double type sigmaSpace needs double type sp needs double type sr needs double type width needs int type height needs int type x direction needs float type y direction needs float type blockSize needs int type c needs int type threshold1 needs double type threshold2 needs double type apertureSize needs int type hmin needs int type smin needs int type vmin needs int type hmax needs int type smax needs int type vmax needs int type sigma1 needs double type sigma2 needs double type size needs int type 🤖 Update Parameters: Request failed: Unknown error Contrast Hue Saturation Lightness Temperature Highlight Shadow Sharpen Corner No significant changes This module uses OpenCV C++ algorithms and is currently only suitable for quick validation and parameter tuning of simple CV algorithms. Home Binary Image Edge Detection Contour Analysis Image Enhancement Image Denoising Morphological Operations Template Matching Parameter History Please binarize the current image first Please select Canny operator Contour Filter Settings Contour Display Settings Perimeter Area Roundness Show Original Image Show Bounding Rectangle Show Center Perimeter maximum needs double type Area minimum needs double type Area maximum needs double type Roundness minimum needs double type Roundness maximum needs double type Aspect ratio minimum needs double type Aspect ratio maximum needs double type Perimeter needs at least one minimum or maximum value Area needs at least one minimum or maximum value Roundness needs at least one minimum or maximum value Aspect ratio needs at least one minimum or maximum value Please binarize the current image first Laplace Sharpen USM Sharpen Auto Color Balance clipLimit needs double type size needs int type gamma needs float type Radius needs int type Threshold needs int type Amount needs int type Ratio needs int type Operating Elements Structural Elements Erosion Dilation Closing Morphological Gradient Top Hat Black Hat Hit Miss Cross Ellipse width needs int type height needs int type Original Image Matching Grayscale Matching Edge Matching Import Template: Delete source image Please import template file first angleStart needs int type, and angleStart >= 0 angleEnd needs int type, and angleEnd <= 360 angleStep needs int type, and angleStep > 0 scaleStart needs double type, and scaleStart >= 0 scaleEnd needs double type, and scaleStart <= 1.0 scaleStep needs double type, and scaleStep > 0 matchTemplateThreshold needs double type, and matchTemplateThreshold >= 0 scoreThreshold needs float type, and scoreThreshold >= 0 nmsThreshold needs float type, and nmsThreshold >= 0 Template Matching Operation Time Parameters Description Threshold type cancelled Threshold type selected Global threshold segmentation cancelled Global threshold segmentation selected Adaptive threshold segmentation cancelled Adaptive threshold segmentation selected OpenCVDebugView initialization on startup OpenCVDebugView resource cleanup on close Histogram Equalization CLAHE Gamma Transform Laplace Sharpen USM Sharpen Auto Color Balance Adaptive Threshold Algorithm Adaptive Threshold Segmentation Algorithm Service Error Global Threshold Segmentation Laplace Operator Opening Perimeter minimum needs double type Please select image first Roberts Operator Show Minimum Area Rectangle Gemini API Key Gemini API Key Gemini API key for natural language color correction AI Service Provider DeepSeek Gemini Select AI Service Provider Language Settings Current Language Brush Settings Clear Canvas Revoke Stroke Cap Stroke Join Confirm Cancel Pro Version Test Version Check Service Status Algorithm Service Available Algorithm Service Unavailable Options Settings R needs int type G needs int type B needs int type size needs int type maxHistorySizeText needs int type Please enter a valid url Please load an image first Preview Effect Previous Step Cancel Filter Operation Save Delete Original Image Remark Please configure DeepSeek API Key in general settings first Please configure Gemini API Key in general settings first Options Settings Initialize Filter Parameters Config Clear Cache Data Algorithm Service URL Please enter a complete algorithm service URL address Is the algorithm service available algorithm service available algorithm service unavailable Theme Operations Current Language Chinese English Language Switch Language Operations Reset to Chinese R needs int type G needs int type B needs int type size needs int type Please enter a valid url maxHistorySizeText needs int type Doodle Image Shape Drawing Color Picker Generate GIF Image Crop Color Correction Apply Filter OpenCV Debug Face Swap Image Cartoonization Image Compression Web Long Screenshot Preview Node.js not detected Please install Node.js first (https://nodejs.org/) Website URL Enter URL Screenshot Options Full Page Screenshot Wait Until Timeout (ms) Viewport Width Viewport Height Screenshot Clarity Scale Invalid Clarity Scale Enter a valid scale between 0 and 4, such as 1.5 or 2.0 Capture Screenshot Reset Tip: Make sure Node.js and Playwright are installed. First-time use requires running 'npx playwright install chromium' to install the browser. Invalid URL Please enter a valid URL (starting with http:// or https://) ================================================ FILE: i18n/src/main/resources/strings/strings_zh.xml ================================================ Monica Monica 是一个跨平台图像编辑器 软件版本信息 打开本地图片 加载网络图片 保存图像 截屏(全屏) 截屏(区域选择) 网页长截图 退出 控制面板 通用设置 基础功能 图像调色 滤镜效果 AI 实验室 设置 Monica 通用设置 更新 关闭 取消 撤销 没有可撤销的操作 已撤销 已撤销并重置 已重置 图像模糊 图像马赛克 图像涂鸦 图形绘制 颜色选择器 图像裁剪 生成 GIF 选择颜色 更改属性 线条 圆形 三角形 矩形 多边形 添加文字 保存 画笔 橡皮擦 清除 画笔设置 清空画布 撤回 自然语言调色 请输入调色指令 参数已更新:%s 自然语言图像调色 AI 服务提供商 DeepSeek Gemini 对比度 色调 饱和度 亮度 色温 需要配置 API Key 更新参数: DeepSeek API Key 未配置 Gemini API Key 未配置 请求失败: 未知错误 高光 阴影 锐化 边角 无明显变化 滤镜参数 AI 实验室 简单 CV 算法的快速验证 人脸检测 生成素描 人脸替换 动漫风格 首页 二值图像 边缘检测 轮廓分析 图像增强 图像去噪 形态学操作 模板匹配 参数历史 裁剪类型 内容缩放 宽高比 裁剪框 裁剪属性设置 确认 取消 加载中... 错误 成功 警告 信息 透明度 字体大小 填充 边框 描边宽度 描边端点 描边连接 确认 正式版本 测试版本 检测服务状态 算法服务可用 算法服务不可用 选项设置 R 需要 int 类型 G 需要 int 类型 B 需要 int 类型 size 需要 int 类型 maxHistorySizeText 需要 int 类型 请输入一个正确的 url 请先加载图片 预览效果 上一步 取消滤镜操作 保存 删除原图 备注 语言设置 当前语言 加载网络图片 基础设置 API设置 主题设置 语言设置 当前主题 选择主题 浅色主题 深色主题 蓝色主题 绿色主题 紫色主题 橙色主题 粉色主题 重置为默认主题 请输入图片URL 加载 无效的URL格式 版本信息 © 2024 Tony Shen. 保留所有权利。 基础功能已取消 基础功能已选择 通用设置已取消 通用设置已选择 图像调色已取消 图像调色已选择 滤镜效果已取消 滤镜效果已选择 AI 实验室已取消 AI 实验室已选择 简单 CV 算法的快速验证 人脸检测 生成素描 人脸替换 动漫风格 Monica 软件信息 Monica 版本:%s,%s,构建时间:%s OpenCV 版本:%s,本地算法库:%s 版权:版权所有 2024-至今,Tony Shen Github 地址:https://github.com/fengzhizi715/Monica 输出框颜色设置: 区域大小设置(用于模糊/马赛克): 每个模块的最大历史记录大小: 算法服务地址: 初始化滤镜参数配置 清除所有缓存数据 重置 X 方向 Y 方向 进入滤镜界面 请选择滤镜 %s 滤镜 请先选择滤镜 图像编辑器:滤镜模块 导出 调整 重置滤镜 应用 应用滤镜失败 滤镜已应用 暂无图片 未找到匹配的滤镜 参数摘要 当前为默认参数 已调整 %d 项 提示:可在底部点击“重置滤镜”恢复默认参数 清除滤镜 秋色 骨骼 冷色 暖色 HSV 喷射 海洋 粉色 彩虹 春天 夏天 冬天 大气 燃烧 雾霾 冰冻 熔岩 金属 海洋 水流 备注: 搜索 适应 请选择动漫风格 请先添加图片 选择图片 GIF 生成策略 GIF 宽度 GIF 高度 帧间隔(毫秒) 循环播放 高斯滤波 中值滤波 高斯双边滤波 均值漂移滤波 灰度图像 阈值分割 Canny 边缘检测 彩色图像分割 模板 匹配方式 旋转 最小角度 最大角度 角度步长 缩放 最小缩放 最大缩放 缩放步长 模板匹配参数 阈值 NMS 参数 分数阈值 NMS 阈值 过滤设置 最小值 最大值 最小值 最大值 最小值 最大值 最小值 最大值 显示 设置 操作元素 结构元素 宽度 高度 直方图均衡化 对比度 受限自适应 直方图均衡化 (CLAHE) 伽马变换 拉普拉斯锐化 USM 锐化 自动色彩平衡 边缘检测 算子 替换目标中的人脸数量 点击选择图片 Monica 图像编辑器 通知 导出图片 最小值 最大值 恢复 恢复 最初 上一步 放大预览 删除 图像调色 进入图像调色界面 图像翻转 图像旋转 图像 缩放 图像错切 图像裁剪 图像压缩 原始 压缩后 输入选择 单张图片 文件夹(批量) 压缩模式 压缩算法 质量设置(视觉无损) 值越高,压缩率越低,文件越大;值越低,压缩率越高,但可能失去细节 压缩级别 级别越高,压缩率越高,但处理时间越长;级别越低,处理速度越快 压缩图片 选择输入文件夹 选择输出文件夹 开始批量压缩 压缩结果 原始大小 压缩后大小 压缩率 变大 提示:压缩后文件反而更大。建议降低质量或更换算法(例如 JPG 用 JPEG 质量压缩;截图/透明图用 PNG 优化)。 已选择 错误:请先选择图片 正在压缩图片... 当前系统不支持 WebP 格式,已自动转换为 %s WebP 编码失败,已自动转换为 %s 压缩成功! 压缩失败 压缩异常: %s 准备批量压缩... 文件夹中没有图片 压缩已取消 正在压缩: %s (%d/%d) 无法读取图片 批量压缩完成(成功: %d/%d) 批量压缩异常: %s 请选择要压缩的图片 选择图片 请先选择或加载图片 文件覆盖确认 文件 \"%s\" 已存在,是否覆盖? 保存压缩后的图片 请先执行压缩 开始压缩 请先在右侧预览区选择图片 批量模式不支持预览对比,请查看进度与统计结果 重置 已应用到编辑器 应用到编辑器 保存成功:%s 保存失败 保存 无法读取图片文件 加载图片失败: %s 已清除图像 清除图像 当前系统不支持 WebP,将自动转换为 %s 批量压缩警告 输出文件夹中已有 %d 个文件,批量压缩可能会覆盖同名文件。是否继续? 取消 注意:将 JPG 转换为 PNG 可能导致文件变大,因为 PNG 是无损格式 注意:将 JPG 转换为 WebP Lossless 可能导致文件变大 宫崎骏风格 日系人像风格 Black & White 线条 艺术 新海诚风格 可爱风格 算法 服务 错误 图像灰度化 阈值 类型 全局 阈值分割 自适应 阈值分割 自适应 阈值 算法 Roberts 算子 Prewitt 算子 Sobel 算子 Laplace 算子 LoG 算子 DoG 算子 一阶导数算子 二阶导数算子 一阶导数边缘检测 二阶导数边缘检测 Canny 算子 Canny 边缘检测 请点击选择图像或拖拽图像至此 图像保存成功 图像保存失败 请选择一阶导数算子类型 请选择二阶导数算子类型 请选择阈值化类型 请选择全局阈值分割类型 请选择自适应阈值算法类型 请选择阈值化类型 and global threshold segmentation or adaptive threshold segmentation ksize 需要 int 类型 sigmaX 需要 double 类型 sigmaY 需要 double 类型 d 需要 int 类型 sigmaColor 需要 double 类型 sigmaSpace 需要 double 类型 sp 需要 double 类型 sr 需要 double 类型 width 需要 int 类型 height 需要 int 类型 x 方向 需要 float 类型 y 方向 需要 float 类型 blockSize 需要 int 类型 c 需要 int 类型 threshold1 需要 double 类型 threshold2 需要 double 类型 apertureSize 需要 int 类型 hmin 需要 int 类型 smin 需要 int 类型 vmin 需要 int 类型 hmax 需要 int 类型 smax 需要 int 类型 vmax 需要 int 类型 sigma1 需要 double 类型 sigma2 需要 double 类型 size 需要 int 类型 本模块的算法使用 OpenCV C++ 实现,目前只适用于一些简单 CV 算法的快速验证和调参。 首页 二值化 边缘检测 轮廓分析 图像增强 图像降噪 形态学操作 模版匹配 调参历史 请先选择图像 请先将当前图像进行二值化 轮廓过滤设置 轮廓显示设置 周长 面积 圆度 原图显示 外接矩形 最小外接矩形 质心 周长最小值需要 double 类型 周长最大值需要 double 类型 面积最小值需要 double 类型 面积最大值需要 double 类型 圆度最小值需要 double 类型 圆度最大值需要 double 类型 长宽比最小值需要 double 类型 长宽比最大值需要 double 类型 周长至少输入一个最小值或最大值 面积至少输入一个最小值或最大值 圆度至少输入一个最小值或最大值 长宽比至少输入一个最小值或最大值 请先将当前图像进行二值化 Laplace 锐化 USM 锐化 自动色彩均衡 clipLimit 需要 double 类型 size 需要 int 类型 gamma 需要 float 类型 Radius 需要 int 类型 Threshold 需要 int 类型 Amount 需要 int 类型 Ratio 需要 int 类型 操作元素 结构元素 腐蚀 膨胀 开操作 闭操作 形态学梯度 顶帽 黑帽 击中击不中 十字交叉 椭圆形 width 需要 int 类型 height 需要 int 类型 原图匹配 灰度匹配 边缘匹配 导入模版: 删除 source 的图 请先导入模版文件 angleStart 需要 int 类型, 且 angleStart >= 0 angleEnd 需要 int 类型, 且 angleEnd <= 360 angleStep 需要 int 类型, 且 angleStep > 0 scaleStart 需要 double 类型, 且 scaleStart >= 0 scaleEnd 需要 double 类型, 且 scaleStart <= 1.0 scaleStep 需要 double 类型, 且 scaleStep > 0 matchTemplateThreshold 需要 double 类型, 且 matchTemplateThreshold >= 0 scoreThreshold 需要 float 类型, 且 scoreThreshold >= 0 nmsThreshold 需要 float 类型, 且 nmsThreshold >= 0 模版匹配 操作 时间 参数 描述 取消了阈值化类型 勾选了阈值化类型 取消了全局阈值分割 勾选了全局阈值分割 取消了自适应阈值分割 勾选了自适应阈值分割 OpenCVDebugView 启动时初始化 OpenCVDebugView 关闭时释放资源 直方图均衡化 CLAHE 伽马变换 Laplace 锐化 USM 锐化 自动色彩均衡 请选择 Canny 算子 Gemini API Key Gemini API 密钥 用于自然语言调色的 Gemini API 密钥 选项设置 初始化滤镜参数配置 清除缓存数据 算法服务URL 请输入完整的算法服务URL地址 算法服务是否可用 算法服务可用 算法服务不可用 主题操作 当前语言 中文 English 语言切换 语言操作 重置为中文 R 需要 int 类型 G 需要 int 类型 B 需要 int 类型 size 需要 int 类型 请输入一个正确的 url maxHistorySizeText 需要 int 类型 涂鸦图像 形状绘制 图像取色 生成 gif 图像裁剪 图像调色 使用滤镜 简单 CV 算法的快速验证 人脸替换 图像动漫化 图像压缩 网页长截图 放大预览 未检测到 Node.js 环境 请先安装 Node.js (https://nodejs.org/) 网站地址 请输入网页URL 截图选项 全页截图 等待策略 超时时间(毫秒) 视口宽度 视口高度 截图清晰度倍率 清晰度倍率无效 请输入 0 到 4 之间的有效倍率,例如 1.5 或 2.0 开始截图 重置 提示:确保已安装 Node.js 和 Playwright。首次使用需要运行 'npx playwright install chromium' 安装浏览器。 无效的URL 请输入有效的URL(以 http:// 或 https:// 开头) ================================================ FILE: i18n/src/test/kotlin/cn/netdiscovery/monica/i18n/InternationalizationTest.kt ================================================ package cn.netdiscovery.monica.i18n import org.junit.Test import org.junit.Assert.* import org.junit.Before /** * 国际化功能测试 */ class InternationalizationTest { @Test fun `test language enum`() { assertEquals("zh", Language.CHINESE.code) assertEquals("en", Language.ENGLISH.code) assertEquals("中文", Language.CHINESE.displayName) assertEquals("English", Language.ENGLISH.displayName) assertEquals("🇨🇳", Language.CHINESE.flag) assertEquals("🇺🇸", Language.ENGLISH.flag) } @Test fun `test language fromCode`() { assertEquals(Language.CHINESE, Language.fromCode("zh")) assertEquals(Language.ENGLISH, Language.fromCode("en")) assertEquals(Language.CHINESE, Language.fromCode("invalid")) // 默认返回中文 } @Test fun `test system language detection`() { val systemLang = Language.getSystemLanguage() assertTrue(systemLang in Language.values()) } @Test fun `test localization manager`() { // 测试默认语言 val defaultLang = LocalizationManager.currentLanguage assertTrue(defaultLang in Language.values()) // 测试语言切换 val originalLang = LocalizationManager.currentLanguage val newLang = if (originalLang == Language.CHINESE) Language.ENGLISH else Language.CHINESE LocalizationManager.setLanguage(newLang) assertEquals(newLang, LocalizationManager.currentLanguage) // 恢复原语言 LocalizationManager.setLanguage(originalLang) } @Test fun `test string resource loading`() { val chineseResource = LocalizationManager.getXmlResource(Language.CHINESE) val englishResource = LocalizationManager.getXmlResource(Language.ENGLISH) // 测试资源是否加载成功 assertNotNull(chineseResource) assertNotNull(englishResource) // 测试获取字符串 val chineseAppName = chineseResource.get("app_name") val englishAppName = englishResource.get("app_name") assertEquals("Monica", chineseAppName) assertEquals("Monica", englishAppName) } @Test fun `test string resource with parameters`() { val chineseResource = LocalizationManager.getXmlResource(Language.CHINESE) val englishResource = LocalizationManager.getXmlResource(Language.ENGLISH) // 测试带参数的字符串 val chineseParam = chineseResource.get("color_parameters_updated", "亮度+10") val englishParam = englishResource.get("color_parameters_updated", "brightness+10") assertTrue(chineseParam.contains("亮度+10")) assertTrue(englishParam.contains("brightness+10")) } @Test fun `test supported languages`() { val supportedLanguages = LocalizationManager.getSupportedLanguages() assertEquals(2, supportedLanguages.size) assertTrue(supportedLanguages.contains(Language.CHINESE)) assertTrue(supportedLanguages.contains(Language.ENGLISH)) } } ================================================ FILE: i18n/string_manager.sh ================================================ #!/bin/bash # 字符串资源文件综合管理工具 # 功能:检查重复项、清理重复项、比对中英文文件 # 作者: AI Assistant # 版本: 2.0 # 颜色定义 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # 默认参数 VERBOSE=false DRY_RUN=false BACKUP=true AUTO_FIX=false COMPARE_MODE=false CLEANUP_MODE=false POSITION_MODE=false # 显示帮助信息 show_help() { echo "字符串资源文件综合管理工具 v2.0" echo "" echo "用法: $0 [选项] <文件路径>" echo "" echo "模式选项:" echo " -c, --cleanup 清理模式:检查并清理重复项" echo " -m, --compare 比对模式:比对中英文文件差异" echo " -p, --position 位置比对模式:检查相同key的行号是否一致" echo " -a, --all 全功能模式:清理 + 比对" echo "" echo "清理模式选项:" echo " -v, --verbose 详细输出" echo " -d, --dry-run 只检查,不修改文件" echo " -n, --no-backup 不创建备份文件" echo " -f, --auto-fix 自动修复重复项(保留第一次出现的版本)" echo "" echo "示例:" echo " $0 -c src/main/resources/strings/strings_zh.xml # 清理单个文件" echo " $0 -c -f src/main/resources/strings/strings_zh.xml # 自动修复重复项" echo " $0 -m # 比对中英文文件" echo " $0 -p # 检查行号一致性" echo " $0 -a -f # 全功能模式,自动修复" echo " $0 -c -v -d src/main/resources/strings/*.xml # 详细检查,不修改" } # 解析命令行参数 parse_args() { while [[ $# -gt 0 ]]; do case $1 in -h|--help) show_help exit 0 ;; -c|--cleanup) CLEANUP_MODE=true shift ;; -m|--compare) COMPARE_MODE=true shift ;; -a|--all) CLEANUP_MODE=true COMPARE_MODE=true shift ;; -p|--position) POSITION_MODE=true shift ;; -v|--verbose) VERBOSE=true shift ;; -d|--dry-run) DRY_RUN=true shift ;; -n|--no-backup) BACKUP=false shift ;; -f|--auto-fix) AUTO_FIX=true shift ;; -*) echo -e "${RED}错误: 未知选项 $1${NC}" show_help exit 1 ;; *) FILES+=("$1") shift ;; esac done } # 检查文件是否存在 check_file() { local file="$1" if [[ ! -f "$file" ]]; then echo -e "${RED}错误: 文件不存在: $file${NC}" return 1 fi if [[ ! -r "$file" ]]; then echo -e "${RED}错误: 文件不可读: $file${NC}" return 1 fi return 0 } # 验证XML格式 validate_xml() { local file="$1" if ! xmllint --noout "$file" 2>/dev/null; then echo -e "${RED}警告: $file 不是有效的XML文件${NC}" return 1 fi return 0 } # 创建备份文件 create_backup() { local file="$1" if [[ "$BACKUP" == true && "$DRY_RUN" == false ]]; then local timestamp=$(date +"%Y%m%d_%H%M%S") local backup_file="${file}.backup.${timestamp}" cp "$file" "$backup_file" echo -e "${GREEN}已创建备份: $backup_file${NC}" fi } # 分析重复的字符串名称 analyze_duplicates() { local file="$1" local temp_file=$(mktemp) # 提取所有字符串名称 grep -o 'name="[^"]*"' "$file" | sed 's/name="//g' | sed 's/"//g' | sort > "$temp_file" # 查找重复项 local duplicates=$(sort "$temp_file" | uniq -d) if [[ -z "$duplicates" ]]; then echo -e "${GREEN}✓ 没有发现重复的字符串名称${NC}" rm "$temp_file" return 0 else echo -e "${YELLOW}发现 $(echo "$duplicates" | wc -l | tr -d ' ') 个重复的字符串名称:${NC}" echo "$duplicates" | head -10 if [[ $(echo "$duplicates" | wc -l) -gt 10 ]]; then echo "... 还有 $(($(echo "$duplicates" | wc -l) - 10)) 个重复项" fi if [[ "$VERBOSE" == true ]]; then echo "" echo "重复项详情:" echo "$duplicates" | while read name; do echo "" echo "重复项: $name" grep -n "name=\"$name\"" "$file" | while read line; do echo " $line" done done fi rm "$temp_file" return 1 fi } # 检查重复的字符串内容 check_content_duplicates() { local file="$1" local temp_file=$(mktemp) # 提取所有字符串内容 grep -o '>.*<' "$file" | sed 's/^>//' | sed 's/<$//' | sort > "$temp_file" # 查找重复内容 local duplicates=$(sort "$temp_file" | uniq -d) if [[ -z "$duplicates" ]]; then echo -e "${GREEN}✓ 没有发现重复的字符串内容${NC}" rm "$temp_file" return 0 else echo -e "${YELLOW}发现 $(echo "$duplicates" | wc -l | tr -d ' ') 个重复的字符串内容:${NC}" echo "$duplicates" | head -10 if [[ $(echo "$duplicates" | wc -l) -gt 10 ]]; then echo "... 还有 $(($(echo "$duplicates" | wc -l) - 10)) 个重复内容" fi if [[ "$VERBOSE" == true ]]; then echo "" echo "重复内容详情:" echo "$duplicates" | while read content; do echo "" echo "重复内容: $content" grep -n ">$content<" "$file" | while read line; do echo " $line" done done fi rm "$temp_file" return 0 fi } # 自动修复重复项 auto_fix_duplicates() { local file="$1" local temp_file=$(mktemp) # 获取重复的字符串名称 grep -o 'name="[^"]*"' "$file" | sed 's/name="//g' | sed 's/"//g' | sort | uniq -d > "$temp_file" if [[ ! -s "$temp_file" ]]; then echo -e "${GREEN}没有重复项需要修复${NC}" rm "$temp_file" return 0 fi echo -e "${BLUE}开始自动修复重复项...${NC}" # 为每个重复项找到所有行号并删除除第一个之外的所有行 while read duplicate_name; do local line_numbers=$(grep -n "name=\"$duplicate_name\"" "$file" | cut -d: -f1 | tail -n +2) if [[ -n "$line_numbers" ]]; then echo "$line_numbers" | while read line_num; do if [[ "$DRY_RUN" == true ]]; then echo -e "${YELLOW}将删除重复项: $duplicate_name (第${line_num}行)${NC}" else sed -i '' "${line_num}d" "$file" echo -e "${GREEN}已删除重复项: $duplicate_name (第${line_num}行)${NC}" fi done fi done < "$temp_file" if [[ "$DRY_RUN" == true ]]; then echo -e "${YELLOW}模拟完成!将删除 $(wc -l < "$temp_file") 个重复项${NC}" else echo -e "${GREEN}修复完成!共删除 $(wc -l < "$temp_file") 个重复项${NC}" fi rm "$temp_file" } # 生成文件统计报告 generate_report() { local file="$1" local total_lines=$(wc -l < "$file") local total_strings=$(grep -c 'name="[^"]*"' "$file") local unique_names=$(grep -o 'name="[^"]*"' "$file" | sed 's/name="//g' | sed 's/"//g' | sort | uniq | wc -l) local duplicate_names=$(($total_strings - $unique_names)) echo "=== 文件统计报告 ===" echo "文件: $file" echo "总行数: $total_lines" echo "字符串总数: $total_strings" echo "唯一字符串名称: $unique_names" echo "重复字符串名称: $duplicate_names" echo "" } # 清理模式主函数 cleanup_mode() { local has_errors=0 # 如果没有指定文件,使用默认的中英文文件 if [[ ${#FILES[@]} -eq 0 ]]; then FILES=("src/main/resources/strings/strings_zh.xml" "src/main/resources/strings/strings_en.xml") fi for file in "${FILES[@]}"; do echo "处理文件: $file" echo "==================================" if ! check_file "$file"; then has_errors=1 continue fi if ! validate_xml "$file"; then has_errors=1 continue fi generate_report "$file" local name_duplicates=0 local content_duplicates=0 analyze_duplicates "$file" name_duplicates=$? check_content_duplicates "$file" content_duplicates=$? if [[ "$AUTO_FIX" == true && $name_duplicates -ne 0 ]]; then if [[ "$DRY_RUN" == false ]]; then create_backup "$file" fi auto_fix_duplicates "$file" fi echo "处理完成!" echo "" done return $has_errors } # 位置比对模式主函数 position_compare_mode() { local zh_file="src/main/resources/strings/strings_zh.xml" local en_file="src/main/resources/strings/strings_en.xml" echo "=== 中英文字符串资源文件位置比对 ===" echo "" # 检查文件是否存在 if [[ ! -f "$zh_file" ]]; then echo -e "${RED}错误: 中文文件不存在: $zh_file${NC}" return 1 fi if [[ ! -f "$en_file" ]]; then echo -e "${RED}错误: 英文文件不存在: $en_file${NC}" return 1 fi # 创建临时文件存储key和行号的映射 local zh_temp=$(mktemp) local en_temp=$(mktemp) echo "1. 提取中文文件中的key和行号..." grep -n 'name="[^"]*"' "$zh_file" | sed 's/^\([0-9]*\):.*name="\([^"]*\)".*/\1:\2/' > "$zh_temp" echo "2. 提取英文文件中的key和行号..." grep -n 'name="[^"]*"' "$en_file" | sed 's/^\([0-9]*\):.*name="\([^"]*\)".*/\1:\2/' > "$en_temp" echo "3. 统计信息..." local zh_count=$(wc -l < "$zh_temp") local en_count=$(wc -l < "$en_temp") echo "中文文件字符串数量: $zh_count" echo "英文文件字符串数量: $en_count" echo "" # 找出共同的key local common_keys=$(comm -12 <(cut -d: -f2 "$zh_temp" | sort) <(cut -d: -f2 "$en_temp" | sort)) local common_count=$(echo "$common_keys" | wc -l) echo "4. 共同key数量: $common_count" echo "" # 检查行号差异 local mismatched_count=0 local max_diff=0 local total_diff=0 echo "5. 检查行号一致性..." echo "$common_keys" | while read key; do local zh_line=$(grep ":$key$" "$zh_temp" | cut -d: -f1) local en_line=$(grep ":$key$" "$en_temp" | cut -d: -f1) if [[ -n "$zh_line" && -n "$en_line" ]]; then local diff=$((zh_line - en_line)) local abs_diff=${diff#-} # 取绝对值 if [[ $abs_diff -gt 0 ]]; then mismatched_count=$((mismatched_count + 1)) total_diff=$((total_diff + abs_diff)) if [[ $abs_diff -gt $max_diff ]]; then max_diff=$abs_diff fi if [[ "$VERBOSE" == true ]]; then echo -e "${YELLOW}行号不匹配: $key${NC}" echo " 中文文件第${zh_line}行" echo " 英文文件第${en_line}行" echo " 差异: $diff 行" echo "" fi fi fi done # 由于while循环在子shell中执行,我们需要用其他方式统计 local actual_mismatched=$(echo "$common_keys" | while read key; do local zh_line=$(grep ":$key$" "$zh_temp" | cut -d: -f1) local en_line=$(grep ":$key$" "$en_temp" | cut -d: -f1) if [[ -n "$zh_line" && -n "$en_line" ]]; then local diff=$((zh_line - en_line)) local abs_diff=${diff#-} if [[ $abs_diff -gt 0 ]]; then echo "1" fi fi done | wc -l) local actual_total_diff=$(echo "$common_keys" | while read key; do local zh_line=$(grep ":$key$" "$zh_temp" | cut -d: -f1) local en_line=$(grep ":$key$" "$en_temp" | cut -d: -f1) if [[ -n "$zh_line" && -n "$en_line" ]]; then local diff=$((zh_line - en_line)) local abs_diff=${diff#-} echo "$abs_diff" fi done | awk '{sum+=$1} END {print sum}') local actual_max_diff=$(echo "$common_keys" | while read key; do local zh_line=$(grep ":$key$" "$zh_temp" | cut -d: -f1) local en_line=$(grep ":$key$" "$en_temp" | cut -d: -f1) if [[ -n "$zh_line" && -n "$en_line" ]]; then local diff=$((zh_line - en_line)) local abs_diff=${diff#-} echo "$abs_diff" fi done | sort -n | tail -1) echo "6. 位置比对结果:" if [[ $actual_mismatched -eq 0 ]]; then echo -e "${GREEN}✅ 所有共同key的行号都一致!${NC}" else echo -e "${YELLOW}⚠️ 发现 $actual_mismatched 个key的行号不一致${NC}" echo " 最大行号差异: $actual_max_diff 行" echo " 平均行号差异: $(echo "scale=1; $actual_total_diff / $actual_mismatched" | bc 2>/dev/null || echo "N/A") 行" if [[ "$VERBOSE" == false ]]; then echo "" echo "使用 -v 选项查看详细的不匹配信息" fi fi # 清理临时文件 rm "$zh_temp" "$en_temp" echo "" echo "=== 位置比对完成 ===" echo "" } # 比对模式主函数 compare_mode() { local zh_file="src/main/resources/strings/strings_zh.xml" local en_file="src/main/resources/strings/strings_en.xml" echo "=== 中英文字符串资源文件比对 ===" echo "" # 检查文件是否存在 if [[ ! -f "$zh_file" ]]; then echo -e "${RED}错误: 中文文件不存在: $zh_file${NC}" return 1 fi if [[ ! -f "$en_file" ]]; then echo -e "${RED}错误: 英文文件不存在: $en_file${NC}" return 1 fi # 提取所有字符串名称 echo "1. 提取中文文件中的字符串名称..." local zh_names=$(grep -o 'name="[^"]*"' "$zh_file" | sed 's/name="//g' | sed 's/"//g' | sort) echo "2. 提取英文文件中的字符串名称..." local en_names=$(grep -o 'name="[^"]*"' "$en_file" | sed 's/name="//g' | sed 's/"//g' | sort) echo "3. 统计信息..." local zh_count=$(echo "$zh_names" | wc -l) local en_count=$(echo "$en_names" | wc -l) echo "中文文件字符串数量: $zh_count" echo "英文文件字符串数量: $en_count" echo "" # 找出中文有但英文没有的字符串 echo "4. 中文有但英文没有的字符串:" local missing_in_en=$(comm -23 <(echo "$zh_names") <(echo "$en_names")) if [[ -z "$missing_in_en" ]]; then echo -e "${GREEN}✅ 没有缺失的英文翻译${NC}" else echo "$missing_in_en" | nl fi echo "" # 找出英文有但中文没有的字符串 echo "5. 英文有但中文没有的字符串:" local missing_in_zh=$(comm -13 <(echo "$zh_names") <(echo "$en_names")) if [[ -z "$missing_in_zh" ]]; then echo -e "${GREEN}✅ 没有缺失的中文翻译${NC}" else echo "$missing_in_zh" | nl fi echo "" # 显示具体的缺失内容 if [[ ! -z "$missing_in_en" ]]; then echo "6. 缺失的英文翻译详情:" echo "$missing_in_en" | while read name; do local zh_line=$(grep "name=\"$name\"" "$zh_file") echo "字符串名称: $name" echo "中文内容: $zh_line" echo "---" done fi if [[ ! -z "$missing_in_zh" ]]; then echo "7. 缺失的中文翻译详情:" echo "$missing_in_zh" | while read name; do local en_line=$(grep "name=\"$name\"" "$en_file") echo "字符串名称: $name" echo "英文内容: $en_line" echo "---" done fi echo "=== 比对完成 ===" echo "" } # 主函数 main() { # 初始化文件数组 FILES=() # 解析参数 parse_args "$@" # 检查是否指定了模式 if [[ "$CLEANUP_MODE" == false && "$COMPARE_MODE" == false ]]; then echo -e "${RED}错误: 请指定操作模式 (-c, -m, 或 -a)${NC}" show_help exit 1 fi # 执行清理模式 if [[ "$CLEANUP_MODE" == true ]]; then echo "字符串资源文件清理工具 v2.0" echo "" cleanup_mode fi # 执行比对模式 if [[ "$COMPARE_MODE" == true ]]; then compare_mode fi echo "所有操作完成!" } # 运行主函数 main "$@" ================================================ FILE: imageprocess/build.gradle.kts ================================================ plugins { kotlin("jvm") } repositories { mavenCentral() maven( "https://jitpack.io" ) } dependencies { testImplementation(kotlin("test")) implementation ("org.jetbrains.kotlin:kotlin-stdlib") // coroutines utils implementation ("com.github.fengzhizi715.Kotlin-Coroutines-Utils:common:${rootProject.extra["coroutines.utils"]}") implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:${rootProject.extra["kotlinx.coroutines.core.version"]}") // twelvemonkeys implementation("com.twelvemonkeys.imageio:imageio-core:${rootProject.extra["twelvemonkeys"]}") implementation("com.twelvemonkeys.imageio:imageio-jpeg:${rootProject.extra["twelvemonkeys"]}") implementation("com.twelvemonkeys.imageio:imageio-hdr:${rootProject.extra["twelvemonkeys"]}") // webp implementation("org.sejda.imageio:webp-imageio:0.1.5") // svg implementation("org.apache.xmlgraphics:batik-transcoder:${rootProject.extra["batik"]}") implementation("org.apache.xmlgraphics:batik-codec:${rootProject.extra["batik"]}") implementation("org.apache.xmlgraphics:batik-dom:${rootProject.extra["batik"]}") implementation("org.apache.xmlgraphics:batik-svggen:${rootProject.extra["batik"]}") implementation("org.apache.xmlgraphics:batik-parser:${rootProject.extra["batik"]}") } tasks.test { useJUnitPlatform() } kotlin { jvmToolchain(17) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/BufferedImages.kt ================================================ package cn.netdiscovery.monica.imageprocess import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.BufferedImages * @author: Tony Shen * @date: 2024/5/7 10:46 * @version: V1.0 <描述当前版本功能> */ data class ImageInfo(val width:Int, val height:Int, val byteArray:ByteArray) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as ImageInfo if (width != other.width) return false if (height != other.height) return false return byteArray.contentEquals(other.byteArray) } override fun hashCode(): Int { var result = width result = 31 * result + height result = 31 * result + byteArray.contentHashCode() return result } } class BufferedImages { companion object { fun create(width: Int, height: Int, type: Int): BufferedImage = BufferedImage( if (width > 0) width else 1, if (height > 0) height else 1, type) fun toBufferedImage(pixels: IntArray, width: Int, height: Int, type: Int): BufferedImage { val bi = BufferedImage(width, height, type) bi.setRGB(0, 0, width, height, pixels, 0, width) return bi } } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/Colormap.kt ================================================ package cn.netdiscovery.monica.imageprocess /** * * @FileName: * cn.netdiscovery.monica.imageprocess.Colormap * @author: Tony Shen * @date: 2025/3/22 15:05 * @version: V1.0 <描述当前版本功能> */ interface Colormap { /** * Convert a value in the range 0..1 to an RGB color. * @param v a value in the range 0..1 * @return an RGB color */ fun getColor(v: Float): Int } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/IntIntegralImage.kt ================================================ package cn.netdiscovery.monica.imageprocess import java.util.* /** * * @FileName: * cn.netdiscovery.monica.imageprocess.IntIntegralImage * @author: Tony Shen * @date: 2024/6/22 22:30 * @version: V1.0 <描述当前版本功能> */ class IntIntegralImage { // sum index tables private lateinit var sum: IntArray private lateinit var squaresum: FloatArray private lateinit var image: ByteArray private var width = 0 private var height = 0 fun getImage(): ByteArray = image fun setImage(image: ByteArray) { this.image = image } fun getBlockSum(x1: Int, y1: Int, x2: Int, y2: Int): Int { val tl = sum[y1 * width + x1] val tr = sum[y2 * width + x1] val bl = sum[y1 * width + x2] val br = sum[y2 * width + x2] return br - bl - tr + tl } fun getBlockSquareSum(x1: Int, y1: Int, x2: Int, y2: Int): Float { val tl = squaresum[y1 * width + x1] val tr = squaresum[y2 * width + x1] val bl = squaresum[y1 * width + x2] val br = squaresum[y2 * width + x2] return br - bl - tr + tl } fun calculate(w: Int, h: Int) { // 初始化积分图 width = w + 1 height = h + 1 sum = IntArray(width * height) Arrays.fill(sum, 0) // 计算积分图 var p1 = 0 var p2 = 0 var p3 = 0 var p4: Int for (row in 1 until height) { for (col in 1 until width) { // 计算和查找表 p1 = image[(row - 1) * w + col - 1].toInt() and 0xff // p(x, y) p2 = sum[row * width + col - 1] // p(x-1, y) p3 = sum[(row - 1) * width + col] // p(x, y-1); p4 = sum[(row - 1) * width + col - 1] // p(x-1, y-1); sum[row * width + col] = p1 + p2 + p3 - p4 } } } fun calculate(w: Int, h: Int, sqrtsum: Boolean) { width = w + 1 height = h + 1 sum = IntArray(width * height) squaresum = FloatArray(width * height) Arrays.fill(sum, 0) Arrays.fill(squaresum, 0f) // rows var p1 = 0 var p2 = 0 var p3 = 0 var p4: Int var sp2 = 0f var sp3 = 0f var sp4 = 0f for (row in 1 until height) { for (col in 1 until width) { // 计算和查找表 p1 = image[(row - 1) * w + col - 1].toInt() and 0xff // p(x, y) p2 = sum[row * width + col - 1] // p(x-1, y) p3 = sum[(row - 1) * width + col] // p(x, y-1); p4 = sum[(row - 1) * width + col - 1] // p(x-1, y-1); sum[row * width + col] = p1 + p2 + p3 - p4 // 计算平方查找表 sp2 = squaresum[row * width + col - 1] // p(x-1, y) sp3 = squaresum[(row - 1) * width + col] // p(x, y-1); sp4 = squaresum[(row - 1) * width + col - 1] // p(x-1, y-1); squaresum[row * width + col] = p1 * p1 + sp2 + sp3 - sp4 } } } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/Transformer.kt ================================================ package cn.netdiscovery.monica.imageprocess import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.Transformer * @author: Tony Shen * @date: 2024/4/27 13:32 * @version: V1.0 图像转换的接口 */ interface Transformer { fun transform(image: BufferedImage): BufferedImage } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/domain/ArrayColormap.kt ================================================ package cn.netdiscovery.monica.imageprocess.domain import cn.netdiscovery.monica.imageprocess.Colormap import cn.netdiscovery.monica.imageprocess.math.mixColors /** * * @FileName: * cn.netdiscovery.monica.imageprocess.domain.ArrayColormap * @author: Tony Shen * @date: 2025/3/22 15:07 * @version: V1.0 <描述当前版本功能> */ open class ArrayColormap(var map: IntArray = IntArray(256)) : Colormap, Cloneable { /** * Convert a value in the range 0..1 to an RGB color. * @param v a value in the range 0..1 * @return an RGB color * @see .setColor */ override fun getColor(v: Float): Int { /* v *= 255; int n = (int)v; float f = v-n; if (n < 0) return map[0]; else if (n >= 255) return map[255]; return ImageMath.mixColors(f, map[n], map[n+1]); */ var n = (v * 255).toInt() if (n < 0) n = 0 else if (n > 255) n = 255 return map[n] } /** * Set the color at "index" to "color". Entries are interpolated linearly from * the existing entries at "firstIndex" and "lastIndex" to the new entry. * firstIndex < index < lastIndex must hold. * @param index the position to set * @param firstIndex the position of the first color from which to interpolate * @param lastIndex the position of the second color from which to interpolate * @param color the color to set */ fun setColorInterpolated(index: Int, firstIndex: Int, lastIndex: Int, color: Int) { val firstColor = map[firstIndex] val lastColor = map[lastIndex] for (i in firstIndex..index) map[i] = mixColors((i - firstIndex).toFloat() / (index - firstIndex), firstColor, color) for (i in index until lastIndex) map[i] = mixColors((i - index).toFloat() / (lastIndex - index), color, lastColor) } /** * Set a range of the colormap, interpolating between two colors. * @param firstIndex the position of the first color * @param lastIndex the position of the second color * @param color1 the first color * @param color2 the second color */ fun setColorRange(firstIndex: Int, lastIndex: Int, color1: Int, color2: Int) { for (i in firstIndex..lastIndex) map[i] = mixColors((i - firstIndex).toFloat() / (lastIndex - firstIndex), color1, color2) } /** * Set a range of the colormap to a single color. * @param firstIndex the position of the first color * @param lastIndex the position of the second color * @param color the color */ fun setColorRange(firstIndex: Int, lastIndex: Int, color: Int) { for (i in firstIndex..lastIndex) map[i] = color } /** * Set one element of the colormap to a given color. * @param index the position of the color * @param color the color * @see .getColor */ open fun setColor(index: Int, color: Int) { map[index] = color } public override fun clone(): Any { // try { val g = super.clone() as ArrayColormap g.map = map.clone() return g // } catch (e: CloneNotSupportedException) { // } // return null } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/domain/Gradient.kt ================================================ package cn.netdiscovery.monica.imageprocess.domain import cn.netdiscovery.monica.imageprocess.math.Noise.Companion.lerp import cn.netdiscovery.monica.imageprocess.math.TWO_PI import cn.netdiscovery.monica.imageprocess.math.mixColors import cn.netdiscovery.monica.imageprocess.math.smoothStep import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.Color import kotlin.math.sqrt /** * * @FileName: * cn.netdiscovery.monica.imageprocess.domain.Gradient * @author: Tony Shen * @date: 2025/3/22 15:13 * @version: V1.0 <描述当前版本功能> */ class Gradient : ArrayColormap { private var numKnots = 4 private var xKnots = intArrayOf( -1, 0, 255, 256 ) private var yKnots = intArrayOf( -0x1000000, -0x1000000, -0x1, -0x1, ) private var knotTypes = byteArrayOf((RGB or SPLINE).toByte(), (RGB or SPLINE).toByte(), (RGB or SPLINE).toByte(), (RGB or SPLINE).toByte()) /** * Construct a Gradient. */ constructor() { rebuildGradient() } /** * Construct a Gradient with the given colors. * @param rgb the colors */ constructor(rgb: IntArray) : this(null, rgb, null) /** * Construct a Gradient with the given colors, knot positions and interpolation types. * @param x the knot positions * @param rgb the colors * @param types interpolation types */ /** * Construct a Gradient with the given colors and knot positions. * @param x the knot positions * @param rgb the colors */ @JvmOverloads constructor(x: IntArray?, rgb: IntArray, types: ByteArray? = null) { setKnots(x, rgb, types) } override fun clone(): Any { val g = super.clone() as Gradient g.map = map.clone() g.xKnots = xKnots.clone() g.yKnots = yKnots.clone() g.knotTypes = knotTypes.clone() return g } /** * Copy one Gradient into another. * @param g the Gradient to copy into */ fun copyTo(g: Gradient) { g.numKnots = numKnots g.map = map.clone() g.xKnots = xKnots.clone() g.yKnots = yKnots.clone() g.knotTypes = knotTypes.clone() } /** * Set a knot color. * @param n the knot index * @param color the color */ override fun setColor(n: Int, color: Int) { val firstColor = map[0] val lastColor = map[256 - 1] if (n > 0) for (i in 0 until n) map[i] = mixColors(i.toFloat() / n, firstColor, color) if (n < 256 - 1) for (i in n..255) map[i] = mixColors((i - n).toFloat() / (256 - n), color, lastColor) } /** * Get the number of knots in the gradient. * @return the number of knots. */ fun getNumKnots(): Int { return numKnots } /** * Set a knot color. * @param n the knot index * @param color the color * @see .getKnot */ fun setKnot(n: Int, color: Int) { yKnots[n] = color rebuildGradient() } /** * Get a knot color. * @param n the knot index * @return the knot color * @see .setKnot */ fun getKnot(n: Int): Int { return yKnots[n] } /** * Set a knot type. * @param n the knot index * @param type the type * @see .getKnotType */ fun setKnotType(n: Int, type: Int) { knotTypes[n] = ((knotTypes[n].toInt() and COLOR_MASK.inv()) or type).toByte() rebuildGradient() } /** * Get a knot type. * @param n the knot index * @return the knot type * @see .setKnotType */ fun getKnotType(n: Int): Int { return (knotTypes[n].toInt() and COLOR_MASK).toByte().toInt() } /** * Set a knot blend type. * @param n the knot index * @param type the knot blend type * @see .getKnotBlend */ fun setKnotBlend(n: Int, type: Int) { knotTypes[n] = ((knotTypes[n].toInt() and BLEND_MASK.inv()) or type).toByte() rebuildGradient() } /** * Get a knot blend type. * @param n the knot index * @return the knot blend type * @see .setKnotBlend */ fun getKnotBlend(n: Int): Byte { return (knotTypes[n].toInt() and BLEND_MASK).toByte() } /** * Add a new knot. * @param x the knot position * @param color the color * @param type the knot type * @see .removeKnot */ fun addKnot(x: Int, color: Int, type: Int) { val nx = IntArray(numKnots + 1) val ny = IntArray(numKnots + 1) val nt = ByteArray(numKnots + 1) System.arraycopy(xKnots, 0, nx, 0, numKnots) System.arraycopy(yKnots, 0, ny, 0, numKnots) System.arraycopy(knotTypes, 0, nt, 0, numKnots) xKnots = nx yKnots = ny knotTypes = nt // Insert one position before the end so the sort works correctly xKnots[numKnots] = xKnots[numKnots - 1] yKnots[numKnots] = yKnots[numKnots - 1] knotTypes[numKnots] = knotTypes[numKnots - 1] xKnots[numKnots - 1] = x yKnots[numKnots - 1] = color knotTypes[numKnots - 1] = type.toByte() numKnots++ sortKnots() rebuildGradient() } /** * Remove a knot. * @param n the knot index * @see .addKnot */ fun removeKnot(n: Int) { if (numKnots <= 4) return if (n < numKnots - 1) { System.arraycopy(xKnots, n + 1, xKnots, n, numKnots - n - 1) System.arraycopy(yKnots, n + 1, yKnots, n, numKnots - n - 1) System.arraycopy(knotTypes, n + 1, knotTypes, n, numKnots - n - 1) } numKnots-- if (xKnots[1] > 0) xKnots[1] = 0 rebuildGradient() } /** * Set the values of all the knots. * This version does not require the "extra" knots at -1 and 256 * @param x the knot positions * @param rgb the knot colors * @param types the knot types */ fun setKnots(x: IntArray?, rgb: IntArray, types: ByteArray?) { numKnots = rgb.size + 2 xKnots = IntArray(numKnots) yKnots = IntArray(numKnots) knotTypes = ByteArray(numKnots) if (x != null) System.arraycopy(x, 0, xKnots, 1, numKnots - 2) else { var i = 1 while (i > numKnots - 1) { xKnots[i] = 255 * i / (numKnots - 2) i++ } } System.arraycopy(rgb, 0, yKnots, 1, numKnots - 2) if (types != null) System.arraycopy(types, 0, knotTypes, 1, numKnots - 2) else { var i = 0 while (i > numKnots) { knotTypes[i] = (RGB or SPLINE).toByte() i++ } } sortKnots() rebuildGradient() } /** * Set the values of a set of knots. * @param x the knot positions * @param y the knot colors * @param types the knot types * @param offset the first knot to set * @param count the number of knots */ fun setKnots(x: IntArray?, y: IntArray?, types: ByteArray?, offset: Int, count: Int) { numKnots = count xKnots = IntArray(numKnots) yKnots = IntArray(numKnots) knotTypes = ByteArray(numKnots) System.arraycopy(x, offset, xKnots, 0, numKnots) System.arraycopy(y, offset, yKnots, 0, numKnots) System.arraycopy(types, offset, knotTypes, 0, numKnots) sortKnots() rebuildGradient() } /** * Split a span into two by adding a knot in the middle. * @param n the span index */ fun splitSpan(n: Int) { val x = (xKnots[n] + xKnots[n + 1]) / 2 addKnot(x, getColor(x / 256.0f), knotTypes[n].toInt()) rebuildGradient() } /** * Set a knot position. * @param n the knot index * @param x the knot position * @see .setKnotPosition */ fun setKnotPosition(n: Int, x: Int) { xKnots[n] = clamp(x, 0, 255) sortKnots() rebuildGradient() } /** * Get a knot position. * @param n the knot index * @return the knot position * @see .setKnotPosition */ fun getKnotPosition(n: Int): Int { return xKnots[n] } /** * Return the knot at a given position. * @param x the position * @return the knot number, or 1 if no knot found */ fun knotAt(x: Int): Int { for (i in 1 until numKnots - 1) if (xKnots[i + 1] > x) return i return 1 } private fun rebuildGradient() { xKnots[0] = -1 xKnots[numKnots - 1] = 256 yKnots[0] = yKnots[1] yKnots[numKnots - 1] = yKnots[numKnots - 2] val knot = 0 for (i in 1 until numKnots - 1) { val spanLength = (xKnots[i + 1] - xKnots[i]).toFloat() var end = xKnots[i + 1] if (i == numKnots - 2) end++ for (j in xKnots[i] until end) { val rgb1 = yKnots[i] val rgb2 = yKnots[i + 1] val hsb1 = Color.RGBtoHSB((rgb1 shr 16) and 0xff, (rgb1 shr 8) and 0xff, rgb1 and 0xff, null) val hsb2 = Color.RGBtoHSB((rgb2 shr 16) and 0xff, (rgb2 shr 8) and 0xff, rgb2 and 0xff, null) var t = (j - xKnots[i]).toFloat() / spanLength val type = getKnotType(i) val blend = getKnotBlend(i).toInt() if (j >= 0 && j <= 255) { when (blend) { CONSTANT -> t = 0f LINEAR -> {} SPLINE -> // map[i] = ImageMath.colorSpline(j, numKnots, xKnots, yKnots); t = smoothStep(0.15f, 0.85f, t) CIRCLE_UP -> { t = t - 1 t = sqrt((1 - t * t).toDouble()).toFloat() } CIRCLE_DOWN -> t = 1 - sqrt((1 - t * t).toDouble()).toFloat() } when (type) { RGB -> map[j] = mixColors(t, rgb1, rgb2) HUE_CW, HUE_CCW -> { if (type == HUE_CW) { if (hsb2[0] <= hsb1[0]) hsb2[0] += 1.0f } else { if (hsb1[0] <= hsb2[1]) hsb1[0] += 1.0f } val h: Float = lerp(t, hsb1[0], hsb2[0]) % (TWO_PI) val s: Float = lerp(t, hsb1[1], hsb2[1]) val b: Float = lerp(t, hsb1[2], hsb2[2]) map[j] = -0x1000000 or Color.HSBtoRGB(h, s, b) //FIXME-alpha } }// } } } } } private fun sortKnots() { for (i in 1 until numKnots - 1) { for (j in 1 until i) { if (xKnots[i] < xKnots[j]) { var t = xKnots[i] xKnots[i] = xKnots[j] xKnots[j] = t t = yKnots[i] yKnots[i] = yKnots[j] yKnots[j] = t val bt = knotTypes[i] knotTypes[i] = knotTypes[j] knotTypes[j] = bt } } } } private fun rebuild() { sortKnots() rebuildGradient() } /** * Randomize the gradient. */ fun randomize() { numKnots = 4 + (6 * Math.random()).toInt() xKnots = IntArray(numKnots) yKnots = IntArray(numKnots) knotTypes = ByteArray(numKnots) for (i in 0 until numKnots) { xKnots[i] = (255 * Math.random()).toInt() yKnots[i] = -0x1000000 or ((255 * Math.random()).toInt() shl 16) or ((255 * Math.random()).toInt() shl 8) or (255 * Math.random()).toInt() knotTypes[i] = (RGB or SPLINE).toByte() } xKnots[0] = -1 xKnots[1] = 0 xKnots[numKnots - 2] = 255 xKnots[numKnots - 1] = 256 sortKnots() rebuildGradient() } /** * Mutate the gradient. * @param amount the amount in the range zero to one */ fun mutate(amount: Float) { for (i in 0 until numKnots) { val rgb = yKnots[i] var r = ((rgb shr 16) and 0xff) var g = ((rgb shr 8) and 0xff) var b = (rgb and 0xff) r = clamp((r + amount * 255 * (Math.random() - 0.5)).toInt()) g = clamp((g + amount * 255 * (Math.random() - 0.5)).toInt()) b = clamp((b + amount * 255 * (Math.random() - 0.5)).toInt()) yKnots[i] = -0x1000000 or (r shl 16) or (g shl 8) or b knotTypes[i] = (RGB or SPLINE).toByte() } sortKnots() rebuildGradient() } companion object { /** * Interpolate in RGB space. */ const val RGB: Int = 0x00 /** * Interpolate hue clockwise. */ const val HUE_CW: Int = 0x01 /** * Interpolate hue counter clockwise. */ const val HUE_CCW: Int = 0x02 /** * Interpolate linearly. */ const val LINEAR: Int = 0x10 /** * Interpolate using a spline. */ const val SPLINE: Int = 0x20 /** * Interpolate with a rising circle shape curve. */ const val CIRCLE_UP: Int = 0x30 /** * Interpolate with a falling circle shape curve. */ const val CIRCLE_DOWN: Int = 0x40 /** * Don't tnterpolate - just use the starting value. */ const val CONSTANT: Int = 0x50 private const val COLOR_MASK = 0x03 private const val BLEND_MASK = 0x70 /** * Build a random gradient. * @return the new Gradient */ fun randomGradient(): Gradient { val g = Gradient() g.randomize() return g } } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/domain/Histogram.kt ================================================ package cn.netdiscovery.monica.imageprocess.domain /** * * @FileName: * cn.netdiscovery.monica.imageprocess.domain.Histogram * @author: Tony Shen * @date: 2025/3/20 19:47 * @version: V1.0 <描述当前版本功能> */ class Histogram { companion object { const val RED = 0 const val GREEN = 1 const val BLUE = 2 const val GRAY = 3 } private lateinit var histogram: Array private var numSamples: Int = 0 private lateinit var minValue: IntArray private lateinit var maxValue: IntArray private lateinit var minFrequency: IntArray private lateinit var maxFrequency: IntArray private lateinit var mean: FloatArray private var isGray: Boolean = true constructor() constructor(pixels: IntArray, w: Int, h: Int, offset: Int, stride: Int) { histogram = Array(3) { IntArray(256) } minValue = IntArray(4) maxValue = IntArray(4) minFrequency = IntArray(3) maxFrequency = IntArray(3) mean = FloatArray(3) numSamples = w * h isGray = true var index: Int for (y in 0 until h) { index = offset + y * stride for (x in 0 until w) { val rgb = pixels[index++] val r = (rgb shr 16) and 0xff val g = (rgb shr 8) and 0xff val b = rgb and 0xff histogram[RED][r]++ histogram[GREEN][g]++ histogram[BLUE][b]++ } } for (i in 0 until 256) { if (histogram[RED][i] != histogram[GREEN][i] || histogram[GREEN][i] != histogram[BLUE][i]) { isGray = false break } } for (i in 0 until 3) { for (j in 0 until 256) { if (histogram[i][j] > 0) { minValue[i] = j break } } for (j in 255 downTo 0) { if (histogram[i][j] > 0) { maxValue[i] = j break } } minFrequency[i] = Int.MAX_VALUE maxFrequency[i] = 0 for (j in 0 until 256) { minFrequency[i] = minOf(minFrequency[i], histogram[i][j]) maxFrequency[i] = maxOf(maxFrequency[i], histogram[i][j]) mean[i] += j * histogram[i][j].toFloat() } mean[i] /= numSamples.toFloat() } minValue[GRAY] = minOf(minValue[RED], minValue[GREEN], minValue[BLUE]) maxValue[GRAY] = maxOf(maxValue[RED], maxValue[GREEN], maxValue[BLUE]) } fun isGray(): Boolean = isGray fun getNumSamples(): Int = numSamples fun getFrequency(value: Int): Int = if (numSamples > 0 && isGray && value in 0..255) histogram[0][value] else -1 fun getFrequency(channel: Int, value: Int): Int = if (numSamples < 1 || channel !in 0..2 || value !in 0..255) -1 else histogram[channel][value] fun getMinFrequency(): Int = if (numSamples > 0 && isGray) minFrequency[0] else -1 fun getMinFrequency(channel: Int): Int = if (numSamples < 1 || channel !in 0..2) -1 else minFrequency[channel] fun getMaxFrequency(): Int = if (numSamples > 0 && isGray) maxFrequency[0] else -1 fun getMaxFrequency(channel: Int): Int = if (numSamples < 1 || channel !in 0..2) -1 else maxFrequency[channel] fun getMinValue(): Int = if (numSamples > 0 && isGray) minValue[0] else -1 fun getMinValue(channel: Int): Int = minValue[channel] fun getMaxValue(): Int = if (numSamples > 0 && isGray) maxValue[0] else -1 fun getMaxValue(channel: Int): Int = maxValue[channel] fun getMeanValue(): Float = if (numSamples > 0 && isGray) mean[0] else -1.0F fun getMeanValue(channel: Int): Float = if (numSamples > 0 && channel in RED..BLUE) mean[channel] else -1.0F } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/BilateralFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.BilateralFilter * @author: Tony Shen * @date: 2024/4/29 17:21 * @version: V1.0 <描述当前版本功能> */ class BilateralFilter(private val ds:Double = 1.0, private val rs:Double = 1.0): BaseFilter() { private val factor = -0.5 private var radius = 0 // half-length of Gaussian kernel Adobe Photoshop private lateinit var cWeightTable: Array private lateinit var sWeightTable: DoubleArray override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { radius = Math.max(ds, rs).toInt() buildDistanceWeightTable() buildSimilarityWeightTable() val outPixels = IntArray(width * height) var index = 0 var redSum = 0.0 var greenSum = 0.0 var blueSum = 0.0 var csRedWeight = 0.0 var csGreenWeight = 0.0 var csBlueWeight = 0.0 var csSumRedWeight = 0.0 var csSumGreenWeight = 0.0 var csSumBlueWeight = 0.0 for (row in 0 until height) { var ta = 0 var tr = 0 var tg = 0 var tb = 0 for (col in 0 until width) { index = row * width + col ta = inPixels[index] shr 24 and 0xff tr = inPixels[index] shr 16 and 0xff tg = inPixels[index] shr 8 and 0xff tb = inPixels[index] and 0xff var rowOffset = 0 var colOffset = 0 var index2 = 0 var ta2 = 0 var tr2 = 0 var tg2 = 0 var tb2 = 0 for (semirow in -radius..radius) { for (semicol in -radius..radius) { rowOffset = if (row + semirow >= 0 && row + semirow < height) { row + semirow } else { 0 } colOffset = if (semicol + col >= 0 && semicol + col < width) { col + semicol } else { 0 } index2 = rowOffset * width + colOffset ta2 = inPixels[index2] shr 24 and 0xff tr2 = inPixels[index2] shr 16 and 0xff tg2 = inPixels[index2] shr 8 and 0xff tb2 = inPixels[index2] and 0xff csRedWeight = cWeightTable[semirow + radius][semicol + radius] * sWeightTable[Math.abs(tr2 - tr)] csGreenWeight = cWeightTable[semirow + radius][semicol + radius] * sWeightTable[Math.abs(tg2 - tg)] csBlueWeight = cWeightTable[semirow + radius][semicol + radius] * sWeightTable[Math.abs(tb2 - tb)] csSumRedWeight += csRedWeight csSumGreenWeight += csGreenWeight csSumBlueWeight += csBlueWeight redSum += csRedWeight * tr2.toDouble() greenSum += csGreenWeight * tg2.toDouble() blueSum += csBlueWeight * tb2.toDouble() } } tr = Math.floor(redSum / csSumRedWeight).toInt() tg = Math.floor(greenSum / csSumGreenWeight).toInt() tb = Math.floor(blueSum / csSumBlueWeight).toInt() outPixels[index] = ta shl 24 or (clamp(tr) shl 16) or (clamp(tg) shl 8) or clamp(tb) // clean value for next time... blueSum = 0.0 greenSum = blueSum redSum = greenSum csBlueWeight = 0.0 csGreenWeight = csBlueWeight csRedWeight = csGreenWeight csSumBlueWeight = 0.0 csSumGreenWeight = csSumBlueWeight csSumRedWeight = csSumGreenWeight } } setRGB(dstImage, 0, 0, width, height, outPixels) return dstImage } private fun buildDistanceWeightTable() { val size: Int = 2 * radius + 1 cWeightTable = Array(size) { DoubleArray(size) } for (semirow in -radius..radius) { for (semicol in -radius..radius) { // calculate Euclidean distance between center point and close pixels val delta = Math.sqrt((semirow * semirow + semicol * semicol).toDouble()) / ds val deltaDelta = delta * delta cWeightTable.get(semirow + radius)[semicol + radius] = Math.exp(deltaDelta * factor) } } } /** * for gray image * @param row * @param col * @param inPixels */ private fun buildSimilarityWeightTable() { sWeightTable = DoubleArray(256) // since the color scope is 0 ~ 255 for (i in 0..255) { val delta = Math.sqrt((i * i).toDouble()) / rs val deltaDelta = delta * delta sWeightTable[i] = Math.exp(deltaDelta * factor) } } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/BlockFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import java.awt.image.BufferedImage import kotlin.math.max import kotlin.math.min /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.BlockFilter * @author: Tony Shen * @date: 2024/5/4 23:35 * @version: V1.0 <描述当前版本功能> */ class BlockFilter(blockSize: Int = 2) : BaseFilter() { // blockSize 会被用作 Kotlin range 的 step,必须 > 0 private val blockSize: Int = max(1, blockSize) override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val pixels = IntArray(blockSize * blockSize) for (y in 0 until height step blockSize){ for (x in 0 until width step blockSize){ val w = min(blockSize, width - x) val h = min(blockSize, height - y) val t = w * h getRGB(srcImage, x, y, w, h, pixels) var r = 0 var g = 0 var b = 0 var argb: Int var i = 0 for (by in 0 until h) { for (bx in 0 until w) { argb = pixels[i] r += argb shr 16 and 0xff g += argb shr 8 and 0xff b += argb and 0xff i++ } } argb = r / t shl 16 or (g / t shl 8) or b / t i = 0 for (by in 0 until h) { for (bx in 0 until w) { pixels[i] = pixels[i] and -0x1000000 or argb i++ } } setRGB(dstImage, x, y, w, h, pixels) } } return dstImage } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/BumpFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.ConvolveFilter /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.BumpFilter * @author: Tony Shen * @date: 2024/5/5 18:06 * @version: V1.0 <描述当前版本功能> */ class BumpFilter : ConvolveFilter(embossMatrix) { companion object { private val embossMatrix = floatArrayOf( -1.0f, -1.0f, 0.0f, -1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f ) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/CarveFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.ColorProcessorFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.CarveFilter * @author: Tony Shen * @date: 2025/3/17 12:36 * @version: V1.0 <描述当前版本功能> */ class CarveFilter: ColorProcessorFilter() { override fun doColorProcessor(dstImage: BufferedImage): BufferedImage { val output = Array(3) { ByteArray(R.size) } var index = 0 for (row in 1.. */ open class CellularFilter(open var angle: Double = 0.0, open var scale: Float = 32f, open var randomness: Float = 0f, open var gridType: Int = HEXAGONAL) : WholeImageFilter(), Function2D, Cloneable { companion object { private var probabilities: ByteArray? = null const val RANDOM: Int = 0 const val SQUARE: Int = 1 const val HEXAGONAL: Int = 2 const val OCTAGONAL: Int = 3 const val TRIANGULAR: Int = 4 } protected var stretch: Float = 1.0f var amount: Float = 1.0f var turbulence: Float = 1.0f var gain: Float = 0.5f var bias: Float = 0.5f var distancePower: Float = 2f var useColor: Boolean = false protected var colormap: Colormap = Gradient() protected var coefficients: FloatArray = floatArrayOf(1f, 0f, 0f, 0f) protected var angleCoefficient: Float = 0f protected var random: Random = Random() protected var m00: Float = 1.0f protected var m01: Float = 0.0f protected var m10: Float = 0.0f protected var m11: Float = 1.0f protected var results: Array private val min = 0f private val max = 0f private var gradientCoefficient = 0f init { results = arrayOfNulls(3) for (j in results.indices) results[j] = Point() if (probabilities == null) { probabilities = ByteArray(8192) var factorial = 1f var total = 0f val mean = 2.5f for (i in 0..9) { if (i > 1) factorial *= i.toFloat() val probability = mean.pow(i) * exp(-mean.toDouble()).toFloat() / factorial val start = (total * 8192).toInt() total += probability val end = (total * 8192).toInt() for (j in start until end) probabilities!![j] = i.toByte() } } val cos = cos(angle).toFloat() val sin = sin(angle).toFloat() m00 = cos m01 = sin m10 = -sin m11 = cos } inner class Point { var index: Int = 0 var x: Float = 0f var y: Float = 0f var dx: Float = 0f var dy: Float = 0f var cubeX: Float = 0f var cubeY: Float = 0f var distance: Float = 0f } private fun checkCube(x: Float, y: Float, cubeX: Int, cubeY: Int, results: Array?): Float { random.setSeed((571 * cubeX + 23 * cubeY).toLong()) val numPoints = when (gridType) { RANDOM -> probabilities!![random.nextInt() and 0x1fff].toInt() SQUARE -> 1 HEXAGONAL -> 1 OCTAGONAL -> 2 TRIANGULAR -> 2 else -> probabilities!![random.nextInt() and 0x1fff].toInt() } for (i in 0 until numPoints) { var px = 0f var py = 0f var weight = 1.0f when (gridType) { RANDOM -> { px = random.nextFloat() py = random.nextFloat() } SQUARE -> { py = 0.5f px = py if (randomness != 0f) { px += (randomness * (random.nextFloat() - 0.5)).toFloat() py += (randomness * (random.nextFloat() - 0.5)).toFloat() } } HEXAGONAL -> { if ((cubeX and 1) == 0) { px = 0.75f py = 0f } else { px = 0.75f py = 0.5f } if (randomness != 0f) { px += randomness * Noise.noise2(271 * (cubeX + px), 271 * (cubeY + py)) py += randomness * Noise.noise2(271 * (cubeX + px) + 89, 271 * (cubeY + py) + 137) } } OCTAGONAL -> { when (i) { 0 -> { px = 0.207f py = 0.207f } 1 -> { px = 0.707f py = 0.707f weight = 1.6f } } if (randomness != 0f) { px += randomness * Noise.noise2(271 * (cubeX + px), 271 * (cubeY + py)) py += randomness * Noise.noise2(271 * (cubeX + px) + 89, 271 * (cubeY + py) + 137) } } TRIANGULAR -> { if ((cubeY and 1) == 0) { if (i == 0) { px = 0.25f py = 0.35f } else { px = 0.75f py = 0.65f } } else { if (i == 0) { px = 0.75f py = 0.35f } else { px = 0.25f py = 0.65f } } if (randomness != 0f) { px += randomness * Noise.noise2(271 * (cubeX + px), 271 * (cubeY + py)) py += randomness * Noise.noise2(271 * (cubeX + px) + 89, 271 * (cubeY + py) + 137) } } } var dx = abs((x - px).toDouble()).toFloat() var dy = abs((y - py).toDouble()).toFloat() dx *= weight dy *= weight var d = if (distancePower == 1.0f) dx + dy else if (distancePower == 2.0f) sqrt((dx * dx + dy * dy).toDouble()).toFloat() else (dx.pow(distancePower) + dy.pow(distancePower)).pow((1 / distancePower)) // Insertion sort the long way round to speed it up a bit if (d < results!![0]!!.distance) { val p = results[2] results[2] = results[1] results[1] = results[0] results[0] = p p!!.distance = d p.dx = dx p.dy = dy p.x = cubeX + px p.y = cubeY + py } else if (d < results[1]!!.distance) { val p = results[2] results[2] = results[1] results[1] = p p!!.distance = d p.dx = dx p.dy = dy p.x = cubeX + px p.y = cubeY + py } else if (d < results[2]!!.distance) { val p = results[2] p!!.distance = d p.dx = dx p.dy = dy p.x = cubeX + px p.y = cubeY + py } } return results!![2]!!.distance } override fun evaluate(x: Float, y: Float): Float { for (j in results.indices) results[j]!!.distance = Float.POSITIVE_INFINITY val ix = x.toInt() val iy = y.toInt() val fx = x - ix val fy = y - iy var d = checkCube(fx, fy, ix, iy, results) if (d > fy) d = checkCube(fx, fy + 1, ix, iy - 1, results) if (d > 1 - fy) d = checkCube(fx, fy - 1, ix, iy + 1, results) if (d > fx) { checkCube(fx + 1, fy, ix - 1, iy, results) if (d > fy) d = checkCube(fx + 1, fy + 1, ix - 1, iy - 1, results) if (d > 1 - fy) d = checkCube(fx + 1, fy - 1, ix - 1, iy + 1, results) } if (d > 1 - fx) { d = checkCube(fx - 1, fy, ix + 1, iy, results) if (d > fy) d = checkCube(fx - 1, fy + 1, ix + 1, iy - 1, results) if (d > 1 - fy) d = checkCube(fx - 1, fy - 1, ix + 1, iy + 1, results) } var t = 0f for (i in 0..2) t += coefficients[i] * results!![i]!!.distance if (angleCoefficient != 0f) { var angle = atan2((y - results!![0]!!.y).toDouble(), (x - results!![0]!!.x).toDouble()).toFloat() if (angle < 0) angle += 2 * Math.PI.toFloat() angle /= 4 * Math.PI.toFloat() t += angleCoefficient * angle } if (gradientCoefficient != 0f) { val a = 1 / (results!![0]!!.dy + results!![0]!!.dx) t += gradientCoefficient * a } return t } private fun turbulence2(x: Float, y: Float, freq: Float): Float { var t = 0.0f var f = 1.0f while (f <= freq) { t += evaluate(f * x, f * y) / f f *= 2f } return t } open fun getPixel(x: Int, y: Int, inPixels: IntArray, width: Int, height: Int): Int { var nx = m00 * x + m01 * y var ny = m10 * x + m11 * y nx /= scale ny /= scale * stretch nx += 1000f ny += 1000f // Reduce artifacts around 0,0 var f = if (turbulence == 1.0f) evaluate(nx, ny) else turbulence2(nx, ny, turbulence) // Normalize to 0..1 // f = (f-min)/(max-min); f *= 2f f *= amount val a = 0xff000000.toInt() var v: Int if (colormap != null) { v = colormap.getColor(f) if (useColor) { val srcx: Int = clamp(((results[0]!!.x - 1000) * scale).toInt(), 0, width - 1) val srcy: Int = clamp(((results[0]!!.y - 1000) * scale).toInt(), 0, height - 1) v = inPixels[srcy * width + srcx] f = (results[1]!!.distance - results[0]!!.distance) / (results[1]!!.distance + results[0]!!.distance) f = smoothStep(coefficients[1], coefficients[0], f) v = mixColors(f, 0xff000000.toInt(), v) } return v } else { v = clamp((f * 255).toInt()) val r = v shl 16 val g = v shl 8 val b = v return a or r or g or b } } override fun filterPixels(width: Int, height: Int, inPixels: IntArray, transformedSpace: Rectangle): IntArray { // float[] minmax = Noise.findRange(this, null); // min = minmax[0]; // max = minmax[1]; var index = 0 val outPixels = IntArray(width * height) for (y in 0 until height) { for (x in 0 until width) { outPixels[index++] = getPixel(x, y, inPixels, width, height) } } return outPixels } public override fun clone(): Any { val f = super.clone() as CellularFilter f.coefficients = coefficients.clone() f.results = results.clone() f.random = Random() return f } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/ColorFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.ColorProcessorFilter import cn.netdiscovery.monica.imageprocess.lut.* import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.ColorFilter * @author: Tony Shen * @date: 2024/6/17 14:23 * @version: V1.0 <描述当前版本功能> */ class ColorFilter(val style: Int = 0) : ColorProcessorFilter() { private fun getStyleLUT(style: Int): Array = getColorFilterLUT(style) override fun doColorProcessor(dstImage: BufferedImage): BufferedImage { var tr = 0 var tg = 0 var tb = 0 val lut = getStyleLUT(style) val size: Int = R.size for (i in 0 until size) { tr = R[i].toInt() and 0xff tg = G[i].toInt() and 0xff tb = B[i].toInt() and 0xff R[i] = lut[tr][0].toByte() G[i] = lut[tg][1].toByte() B[i] = lut[tb][2].toByte() } return toBufferedImage(dstImage) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/ColorHalftoneFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import cn.netdiscovery.monica.imageprocess.math.mod import cn.netdiscovery.monica.imageprocess.math.smoothStep import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage import kotlin.math.cos import kotlin.math.min import kotlin.math.sin import kotlin.math.sqrt /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.ColorHalftoneFilter * @author: Tony Shen * @date: 2025/3/19 13:36 * @version: V1.0 <描述当前版本功能> */ class ColorHalftoneFilter(private val dotRadius:Float = 2f): BaseFilter() { private val cyanScreenAngle = Math.toRadians(108.0).toFloat() private val magentaScreenAngle = Math.toRadians(162.0).toFloat() private val yellowScreenAngle = Math.toRadians(90.0).toFloat() override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val gridSize = 2 * dotRadius * 1.414f val angles = floatArrayOf(cyanScreenAngle, magentaScreenAngle, yellowScreenAngle) val mx = floatArrayOf(0f, -1f, 1f, 0f, 0f) val my = floatArrayOf(0f, 0f, 0f, -1f, 1f) val halfGridSize = gridSize / 2 val outPixels = IntArray(width) val inPixels = getRGB(srcImage, 0, 0, width, height, null) for (y in 0.. */ class ConBriFilter(private val contrast:Float = 1.5f,private val brightness:Float =1.0f): ColorProcessorFilter() { override fun doColorProcessor(dstImage: BufferedImage): BufferedImage { // calculate RED, GREEN, BLUE means of pixel var index = 0 val rgbmeans = IntArray(3) var redSum = 0.0 var greenSum = 0.0 var blueSum = 0.0 val total = size.toDouble() for (row in 0 until height) { var tr = 0 var tg = 0 var tb = 0 for (col in 0 until width) { index = row * width + col tr = R[index].toInt() and 0xff tg = G[index].toInt() and 0xff tb = B[index].toInt() and 0xff redSum += tr.toDouble() greenSum += tg.toDouble() blueSum += tb.toDouble() } } rgbmeans[0] = (redSum / total).toInt() rgbmeans[1] = (greenSum / total).toInt() rgbmeans[2] = (blueSum / total).toInt() // adjust contrast and brightness algorithm, here for (row in 0 until height) { var tr = 0 var tg = 0 var tb = 0 for (col in 0 until width) { index = row * width + col tr = R[index].toInt() and 0xff tg = G[index].toInt() and 0xff tb = B[index].toInt() and 0xff // remove means tr -= rgbmeans[0] tg -= rgbmeans[1] tb -= rgbmeans[2] tr *= contrast.toInt() tg *= contrast.toInt() tb *= contrast.toInt() tr += rgbmeans[0] * brightness.toInt() tg += rgbmeans[1] * brightness.toInt() tb += rgbmeans[2] * brightness.toInt() R[index] = clamp(tr).toByte() G[index] = clamp(tg).toByte() B[index] = clamp(tb).toByte() } } return toBufferedImage(dstImage) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/CropFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import java.awt.Graphics2D import java.awt.geom.AffineTransform import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.CropFilter * @author: Tony Shen * @date: 2024/5/5 13:14 * @version: V1.0 <描述当前版本功能> */ class CropFilter(private val x:Int = 0, private val y:Int = 0, private val w:Int = 32, private val h:Int = 32): BaseFilter() { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val dst = BufferedImage(w, h, type) val g: Graphics2D = dst.createGraphics() g.drawRenderedImage(srcImage, AffineTransform.getTranslateInstance(-x.toDouble(), -y.toDouble())) g.dispose() return dst } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/CrystallizeFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.math.mixColors import cn.netdiscovery.monica.imageprocess.math.smoothStep import cn.netdiscovery.monica.imageprocess.utils.clamp /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.CrystallizeFilter * @author: Tony Shen * @date: 2025/3/22 16:50 * @version: V1.0 <描述当前版本功能> */ class CrystallizeFilter(private val edgeThickness:Float = 0.4f, override var scale:Float = 16f, override var randomness:Float = 0f, override var gridType:Int = HEXAGONAL) : CellularFilter(scale = scale, randomness = randomness, gridType = gridType) { private var fadeEdges = false private var edgeColor = 0xff000000.toInt() override fun getPixel(x: Int, y: Int, inPixels: IntArray, width: Int, height: Int): Int { var nx: Float = m00 * x + m01 * y var ny: Float = m10 * x + m11 * y nx /= scale ny /= scale * stretch nx += 1000f ny += 1000f // Reduce artifacts around 0,0 var f: Float = evaluate(nx, ny) val f1: Float = results[0]!!.distance val f2: Float = results[1]!!.distance var srcx: Int = clamp(((results[0]!!.x - 1000) * scale).toInt(), 0, width - 1) var srcy: Int = clamp(((results[0]!!.y - 1000) * scale).toInt(), 0, height - 1) var v = inPixels[srcy * width + srcx] f = (f2 - f1) / edgeThickness f = smoothStep(0f, edgeThickness, f) if (fadeEdges) { srcx = clamp(((results[1]!!.x - 1000) * scale).toInt(), 0, width - 1) srcy = clamp(((results[1]!!.y - 1000) * scale).toInt(), 0, height - 1) var v2 = inPixels[srcy * width + srcx] v2 = mixColors(0.5f, v2, v) v = mixColors(f, v2, v) } else v = mixColors(f, edgeColor, v) return v } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/DiffuseFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.TransformFilter import cn.netdiscovery.monica.imageprocess.math.TWO_PI import kotlin.math.cos import kotlin.math.sin /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.DiffuseFilter * @author: Tony Shen * @date: 2025/3/8 16:05 * @version: V1.0 <描述当前版本功能> */ class DiffuseFilter(private val scale: Float = 4f): TransformFilter() { private lateinit var sinTable: FloatArray private lateinit var cosTable: FloatArray init { edgeAction = CLAMP initialize() } private fun initialize() { sinTable = FloatArray(256) cosTable = FloatArray(256) for (i in 0..255) { val angle: Float = TWO_PI * i / 256f sinTable[i] = (scale * sin(angle.toDouble())).toFloat() cosTable[i]= (scale * cos(angle.toDouble())).toFloat() } } override fun transformInverse(x: Int, y: Int, out: FloatArray) { val angle = (Math.random() * 255).toInt() val distance = Math.random().toFloat() out[0] = x + distance * sinTable[angle] out[1] = y + distance * cosTable.get(angle) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/EmbossFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.ColorProcessorFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.EmbossFilter * @author: Tony Shen * @date: 2024/5/9 11:01 * @version: V1.0 <描述当前版本功能> */ class EmbossFilter(private val colorConstant:Int = 100, private val out:Boolean = false): ColorProcessorFilter() { override fun doColorProcessor(dstImage: BufferedImage): BufferedImage { var offset = 0 var r1 = 0 var g1 = 0 var b1 = 0 var r2 = 0 var g2 = 0 var b2 = 0 var r = 0 var g = 0 var b = 0 for (y in 1 until height - 1) { offset = y * width var ta = 0 for (x in 1 until width - 1) { r1 = R[offset].toInt() and 0xff g1 = G[offset].toInt() and 0xff b1 = B[offset].toInt() and 0xff r2 = R[offset + width].toInt() and 0xff g2 = G[offset + width].toInt() and 0xff b2 = B[offset + width].toInt() and 0xff if (out) { r = r1 - r2 g = g1 - g2 b = b1 - b2 } else { r = r2 - r1 g = g2 - g1 b = b2 - b1 } R[offset] = clamp(r + colorConstant).toByte() G[offset] = clamp(g + colorConstant).toByte() B[offset] = clamp(b + colorConstant).toByte() offset++ } } return toBufferedImage(dstImage) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/EqualizeFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.domain.Histogram import cn.netdiscovery.monica.imageprocess.filter.base.WholeImageFilter import java.awt.Rectangle /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.EqualizeFilter * @author: Tony Shen * @date: 2025/3/20 19:58 * @version: V1.0 <描述当前版本功能> */ class EqualizeFilter: WholeImageFilter() { private var lut: Array?=null override fun filterPixels(width: Int, height: Int, inPixels: IntArray, transformedSpace: Rectangle): IntArray { val histogram = Histogram(inPixels, width, height, 0, width) var i: Int var j: Int if (histogram.getNumSamples() > 0) { val scale: Float = 255.0f / histogram.getNumSamples() lut = Array(3) { IntArray(256) } i = 0 while (i < 3) { lut!![i][0] = histogram.getFrequency(i, 0) j = 1 while (j < 256) { lut!![i][j] = lut!![i][j - 1] + histogram.getFrequency(i, j) j++ } j = 0 while (j < 256) { lut!![i][j] = Math.round(lut!![i][j] * scale) j++ } i++ } } else lut = null i = 0 for (y in 0.. */ class ExposureFilter(private val exposure:Float = 1f): TransferFilter(){ override fun transferFunction(f: Float): Float { return 1 - exp((-f * exposure).toDouble()).toFloat() } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/GainFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.TransferFilter import cn.netdiscovery.monica.imageprocess.math.bias import cn.netdiscovery.monica.imageprocess.math.gain /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.GainFilter * @author: Tony Shen * @date: 2025/3/13 10:52 * @version: V1.0 <描述当前版本功能> */ class GainFilter(private val gain:Float = 0.5f, private val bias:Float = 0.5f): TransferFilter() { override fun transferFunction(v: Float): Float { var f = v f = gain(f, gain) f = bias(f, bias) return f } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/GammaFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.GammaFilter * @author: Tony Shen * @date: 2024/4/29 17:34 * @version: V1.0 <描述当前版本功能> */ class GammaFilter(private val gamma:Double = 0.5): BaseFilter() { private val lut: IntArray = IntArray(256) init { setupGammaLut() } private fun setupGammaLut() { for (i in 0..255) { lut[i] = (Math.exp(Math.log(i / 255.0) * gamma) * 255.0).toInt() } } override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(width * height) var index = 0 for (row in 0 until height) { var ta = 0 var tr = 0 var tg = 0 var tb = 0 for (col in 0 until width) { index = row * width + col ta = inPixels[index] shr 24 and 0xff tr = inPixels[index] shr 16 and 0xff tg = inPixels[index] shr 8 and 0xff tb = inPixels[index] and 0xff // LUT search tr = lut[tr] tg = lut[tg] tb = lut[tb] outPixels[index] = ta shl 24 or (clamp(tr) shl 16) or (clamp(tg) shl 8) or clamp(tb) } } setRGB(dstImage, 0, 0, width, height, outPixels) return dstImage } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/GaussianNoiseFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.ColorProcessorFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage import java.util.* /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.GaussianNoiseFilter * @author: Tony Shen * @date: 2025/3/17 12:18 * @version: V1.0 <描述当前版本功能> */ class GaussianNoiseFilter(private val sigma:Int = 25): ColorProcessorFilter() { override fun doColorProcessor(dstImage: BufferedImage): BufferedImage { var r = 0 var g = 0 var b = 0 val total = width * height val random = Random() for (i in 0.. */ // prewitt operator val PREWITT_X = arrayOf(intArrayOf(-1, 0, 1), intArrayOf(-1, 0, 1), intArrayOf(-1, 0, 1)) val PREWITT_Y = arrayOf(intArrayOf(-1, -1, -1), intArrayOf(0, 0, 0), intArrayOf(1, 1, 1)) // sobel operator val SOBEL_X = arrayOf(intArrayOf(-1, 0, 1), intArrayOf(-2, 0, 2), intArrayOf(-1, 0, 1)) val SOBEL_Y = arrayOf(intArrayOf(-1, -2, -1), intArrayOf(0, 0, 0), intArrayOf(1, 2, 1)) // direction parameter val X_DIRECTION = 0 val Y_DIRECTION = 2 val XY_DIRECTION = 4 class GradientFilter(val direction: Int = XY_DIRECTION, val isSobel:Boolean = true): BaseFilter() { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(width * height) var index = 0 var index2 = 0 var xred = 0.0 var xgreen = 0.0 var xblue = 0.0 var yred = 0.0 var ygreen = 0.0 var yblue = 0.0 var newRow: Int var newCol: Int for (row in 0 until height) { val ta = 255 var tr = 0 var tg = 0 var tb = 0 for (col in 0 until width) { index = row * width + col for (subrow in -1..1) { for (subcol in -1..1) { newRow = row + subrow newCol = col + subcol if (newRow < 0 || newRow >= height) { newRow = row } if (newCol < 0 || newCol >= width) { newCol = col } index2 = newRow * width + newCol tr = inPixels[index2] shr 16 and 0xff tg = inPixels[index2] shr 8 and 0xff tb = inPixels[index2] and 0xff if (isSobel) { xred += SOBEL_X[subrow + 1][subcol + 1] * tr xgreen += SOBEL_X[subrow + 1][subcol + 1] * tg xblue += SOBEL_X[subrow + 1][subcol + 1] * tb yred += SOBEL_Y[subrow + 1][subcol + 1] * tr ygreen += SOBEL_Y[subrow + 1][subcol + 1] * tg yblue += SOBEL_Y[subrow + 1][subcol + 1] * tb } else { xred += PREWITT_X[subrow + 1][subcol + 1] * tr xgreen += PREWITT_X[subrow + 1][subcol + 1] * tg xblue += PREWITT_X[subrow + 1][subcol + 1] * tb yred += PREWITT_Y[subrow + 1][subcol + 1] * tr ygreen += PREWITT_Y[subrow + 1][subcol + 1] * tg yblue += PREWITT_Y[subrow + 1][subcol + 1] * tb } } } val mred = Math.sqrt(xred * xred + yred * yred) val mgreen = Math.sqrt(xgreen * xgreen + ygreen * ygreen) val mblue = Math.sqrt(xblue * xblue + yblue * yblue) if (XY_DIRECTION === direction) { outPixels[index] = ta shl 24 or (clamp(mred.toInt()) shl 16) or (clamp(mgreen.toInt()) shl 8) or clamp(mblue.toInt()) } else if (X_DIRECTION === direction) { outPixels[index] = ta shl 24 or (clamp(yred.toInt()) shl 16) or (clamp(ygreen.toInt()) shl 8) or clamp(yblue.toInt()) } else if (Y_DIRECTION === direction) { outPixels[index] = ta shl 24 or (clamp(xred.toInt()) shl 16) or (clamp(xgreen.toInt()) shl 8) or clamp(xblue.toInt()) } else { // as default, always XY gradient outPixels[index] = ta shl 24 or (clamp(mred.toInt()) shl 16) or (clamp(mgreen.toInt()) shl 8) or clamp(mblue.toInt()) } // cleanup for next loop newCol = 0 newRow = newCol xblue = 0.0 xgreen = xblue xred = xgreen yblue = 0.0 ygreen = yblue yred = ygreen } } setRGB(dstImage, 0, 0, width, height, outPixels) return dstImage } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/GrayFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import java.awt.Color import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.GrayFilter * @author: Tony Shen * @date: 2024/5/1 10:44 * @version: V1.0 <描述当前版本功能> */ class GrayFilter: BaseFilter() { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { for (row in 0 until height) { for (col in 0 until width) { val rgb = srcImage.getRGB(col,row) val r = rgb and (0x00ff0000 shr 16) val g = rgb and (0x0000ff00 shr 8) val b = rgb and 0x000000ff val color = (r * 0.299 + g * 0.587 + b * 0.114).toInt() dstImage.setRGB(col, row, Color(color, color, color).rgb) } } return dstImage } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/HSBAdjustFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.PointFilter import cn.netdiscovery.monica.imageprocess.math.PI import java.awt.Color /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.HSBAdjustFilter * @author: Tony Shen * @date: 2025/3/24 11:29 * @version: V1.0 <描述当前版本功能> */ class HSBAdjustFilter(private val hFactor:Float = 0f, private val sFactor:Float = 0f, private val bFactor:Float = 0f): PointFilter() { private val hsb = FloatArray(3) init { canFilterIndexColorModel = true } override fun filterRGB(x: Int, y: Int, rgb: Int): Int { val a = rgb and 0xff000000.toInt() val r = (rgb shr 16) and 0xff val g = (rgb shr 8) and 0xff val b = rgb and 0xff Color.RGBtoHSB(r, g, b, hsb) hsb[0] += hFactor while (hsb[0] < 0) hsb[0] += PI * 2 hsb[1] += sFactor if (hsb[1] < 0) hsb[1] = 0f else if (hsb[1] > 1.0) hsb[1] = 1.0f hsb[2] += bFactor if (hsb[2] < 0) hsb[2] = 0f else if (hsb[2] > 1.0) hsb[2] = 1.0f return a or (Color.HSBtoRGB(hsb[0], hsb[1], hsb[2]) and 0xffffff) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/HighPassFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.blur.GaussianFilter import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.HighPassFilter * @author: Tony Shen * @date: 2024/5/5 14:00 * @version: V1.0 <描述当前版本功能> */ class HighPassFilter(override val radius: Float =10f): GaussianFilter(radius) { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(width * height) if (radius > 0) { convolveAndTranspose(kernel, inPixels, outPixels, width, height, alpha, alpha && premultiplyAlpha, false, CLAMP_EDGES) convolveAndTranspose(kernel, outPixels, inPixels, height, width, alpha, false, alpha && premultiplyAlpha, CLAMP_EDGES) } getRGB(srcImage, 0, 0, width, height, outPixels) var index = 0 for (y in 0 until height) { for (x in 0 until width) { val rgb1 = outPixels[index] var r1 = rgb1 shr 16 and 0xff var g1 = rgb1 shr 8 and 0xff var b1 = rgb1 and 0xff val rgb2 = inPixels[index] val r2 = rgb2 shr 16 and 0xff val g2 = rgb2 shr 8 and 0xff val b2 = rgb2 and 0xff r1 = (r1 + 255 - r2) / 2 g1 = (g1 + 255 - g2) / 2 b1 = (b1 + 255 - b2) / 2 inPixels[index] = (rgb1 and 0xff000000.toInt()) or (r1 shl 16) or (g1 shl 8) or b1 index++ } } setRGB(dstImage, 0, 0, width, height, inPixels) return dstImage } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/InvertFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.PointFilter /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.InvertFilter * @author: Tony Shen * @date: 2025/3/11 21:16 * @version: V1.0 <描述当前版本功能> */ class InvertFilter:PointFilter() { init { canFilterIndexColorModel = true } override fun filterRGB(x: Int, y: Int, rgb: Int): Int { val a = rgb and 0xff000000.toInt() return a or (rgb.inv() and 0x00ffffff) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/MarbleFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.TransformFilter import cn.netdiscovery.monica.imageprocess.math.Noise import cn.netdiscovery.monica.imageprocess.math.TWO_PI import cn.netdiscovery.monica.imageprocess.utils.clamp import kotlin.math.cos import kotlin.math.sin /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.MarbleFilter * @author: Tony Shen * @date: 2025/3/11 14:35 * @version: V1.0 <描述当前版本功能> */ class MarbleFilter(private val xScale:Float = 4f, private val yScale:Float = 4f, private val turbulence:Float = 1f): TransformFilter() { private lateinit var sinTable: FloatArray private lateinit var cosTable: FloatArray init { edgeAction = CLAMP initialize() } private fun initialize() { sinTable = FloatArray(256) cosTable = FloatArray(256) for (i in 0..255) { val angle: Float = TWO_PI * i / 256f * turbulence sinTable[i] = (-yScale * sin(angle.toDouble())).toFloat() cosTable[i] = (yScale * cos(angle.toDouble())).toFloat() } } override fun transformInverse(x: Int, y: Int, out: FloatArray) { val displacement: Int = displacementMap(x, y) out[0] = x + sinTable[displacement] out[1] = y + cosTable[displacement] } private fun displacementMap(x: Int, y: Int): Int { return clamp((127 * (1 + Noise.noise2(x / xScale, y / xScale))).toInt()) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/MirrorFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import java.awt.* import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.MirrorFilter * @author: Tony Shen * @date: 2025/3/18 20:25 * @version: V1.0 <描述当前版本功能> */ class MirrorFilter(private val opacity:Float = 1.0f, private val centreY:Float = 0.5f, private val gap:Float = 0f): BaseFilter() { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val clip: Shape val h = (centreY * height).toInt() val d = (gap * height).toInt() val g: Graphics2D = dstImage.createGraphics() clip = g.clip g.clipRect(0, 0, width, h) g.drawRenderedImage(srcImage, null) g.clip = clip g.clipRect(0, h + d, width, height - h - d) g.translate(0, 2 * h + d) g.scale(1.0, -1.0) g.drawRenderedImage(srcImage, null) g.paint = GradientPaint(0f, 0f, Color(1.0f, 0.0f, 0.0f, 0.0f), 0f, h.toFloat(), Color(0.0f, 1.0f, 0.0f, opacity)) g.composite = AlphaComposite.getInstance(AlphaComposite.DST_IN) g.fillRect(0, 0, width, h) g.clip = clip g.dispose() return dstImage } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/MosaicFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.IntIntegralImage import cn.netdiscovery.monica.imageprocess.filter.base.ColorProcessorFilter import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.MosaicFilter * @author: Tony Shen * @date: 2024/7/6 14:51 * @version: V1.0 <描述当前版本功能> */ class MosaicFilter(val r:Int=3): ColorProcessorFilter() { override fun doColorProcessor(dstImage: BufferedImage): BufferedImage { val size = (r * 2 + 1) * (r * 2 + 1) var tr = 0 var tg = 0 var tb = 0 var output:Array? = Array(3) { ByteArray(R.size) } val rii = IntIntegralImage() rii.setImage(R) rii.calculate(width, height) val gii = IntIntegralImage() gii.setImage(G) gii.calculate(width, height) val bii = IntIntegralImage() bii.setImage(B) bii.calculate(width, height) var x2 = 0 var y2 = 0 var x1 = 0 var y1 = 0 var index = 0 for (row in 0 until height) { val dy = (row / size) y1 = dy * size y2 = if ((y1 + size) > height) (height - 1) else (y1 + size) index = row * width for (col in 0 until width) { val dx = (col / size) x1 = dx * size x2 = if ((x1 + size) > width) (width - 1) else (x1 + size) val sr = rii.getBlockSum(x1, y1, x2, y2) val sg = gii.getBlockSum(x1, y1, x2, y2) val sb = bii.getBlockSum(x1, y1, x2, y2) val num = (x2 - x1) * (y2 - y1) tr = sr / num tg = sg / num tb = sb / num output!![0][index + col] = tr.toByte() output[1][index + col] = tg.toByte() output[2][index + col] = tb.toByte() } } setRGB(dstImage, width,height, inPixels, output?.get(0)!!, output[1], output[2]) output = null return dstImage } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/NatureFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.ColorProcessorFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.NatureFilter * @author: Tony Shen * @date: 2024/7/11 14:06 * @version: V1.0 <描述当前版本功能> */ class NatureFilter(val style:Int = 0) : ColorProcessorFilter() { val ATMOSPHERE_STYLE = 1 val BURN_STYLE = 2 val FOG_STYLE = 3 val FREEZE_STYLE = 4 val LAVA_STYLE = 5 val METAL_STYLE = 6 val OCEAN_STYLE = 7 val WATER_STYLE = 8 private lateinit var fogLookUp: IntArray init { buildFogLookupTable() } private fun buildFogLookupTable() { fogLookUp = IntArray(256) val fogLimit = 40 for (i in fogLookUp.indices) { if (i > 127) { fogLookUp[i] = i - fogLimit if (fogLookUp[i] < 127) { fogLookUp[i] = 127 } } else { fogLookUp[i] = i + fogLimit if (fogLookUp[i] > 127) { fogLookUp[i] = 127 } } } } override fun doColorProcessor(dstImage: BufferedImage): BufferedImage { val ta = 0 var tr = 0 var tg = 0 var tb = 0 for (i in 0 until size) { tr = R[i].toInt() and 0xff tg = G[i].toInt() and 0xff tb = B[i].toInt() and 0xff val onePixel: IntArray = processOnePixel(ta, tr, tg, tb) R[i] = onePixel[0].toByte() G[i] = onePixel[1].toByte() B[i] = onePixel[2].toByte() } return toBufferedImage(dstImage) } private fun processOnePixel(ta: Int, tr: Int, tg: Int, tb: Int): IntArray { val pixel = IntArray(4) pixel[0] = ta val gray = (tr + tg + tb) / 3 when (style) { ATMOSPHERE_STYLE -> { pixel[1] = (tg + tb) / 2 pixel[2] = (tr + tb) / 2 pixel[3] = (tg + tr) / 2 } BURN_STYLE -> { pixel[1] = clamp(gray * 3) pixel[2] = gray pixel[3] = gray / 3 } FOG_STYLE -> { pixel[1] = fogLookUp[tr] pixel[2] = fogLookUp[tg] pixel[3] = fogLookUp[tb] } FREEZE_STYLE -> { pixel[1] = clamp(Math.abs((tr - tg - tb) * 1.5).toInt()) pixel[2] = clamp(Math.abs((tg - tb - pixel[1]) * 1.5).toInt()) pixel[3] = clamp(Math.abs((tb - pixel[1] - pixel[2]) * 1.5).toInt()) } LAVA_STYLE -> { pixel[1] = gray pixel[2] = Math.abs(tb - 128) pixel[3] = Math.abs(tb - 128) } METAL_STYLE -> { var r = Math.abs(tr - 64).toFloat() var g = Math.abs(r - 64) var b = Math.abs(g - 64) val grayFloat = (222 * r + 707 * g + 71 * b) / 1000 r = grayFloat + 70 r = r + (r - 128) * 100 / 100f g = grayFloat + 65 g = g + (g - 128) * 100 / 100f b = grayFloat + 75 b = b + (b - 128) * 100 / 100f pixel[1] = clamp(r.toInt()) pixel[2] = clamp(g.toInt()) pixel[3] = clamp(b.toInt()) } OCEAN_STYLE -> { pixel[1] = clamp(gray / 3) pixel[2] = gray pixel[3] = clamp(gray * 3) } WATER_STYLE -> { pixel[1] = clamp(gray - tg - tb) pixel[2] = clamp(gray - pixel[1] - tb) pixel[3] = clamp(gray - pixel[1] - pixel[2]) } else -> { pixel[1] = (tg + tb) / 2 pixel[2] = (tr + tb) / 2 pixel[3] = (tg + tr) / 2 } } return pixel } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/OffsetFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.TransformFilter import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.OffsetFilter * @author: Tony Shen * @date: 2025/3/24 09:53 * @version: V1.0 <描述当前版本功能> */ class OffsetFilter(private var xOffset:Int = 0, private var yOffset:Int = 0, private val wrap:Boolean = true): TransformFilter() { init { edgeAction = ZERO } override fun transformInverse(x: Int, y: Int, out: FloatArray) { if ( wrap ) { out[0] = ((x+width-xOffset) % width).toFloat() out[1] = ((y+height-yOffset) % height).toFloat() } else { out[0] = (x-xOffset).toFloat() out[1] = (y-yOffset).toFloat() } } override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { if (wrap) { while (xOffset < 0) xOffset += width while (yOffset < 0) yOffset += height xOffset %= width yOffset %= height } return super.doFilter(srcImage, dstImage) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/OilPaintFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.OilPaintFilter * @author: Tony Shen * @date: 2024/5/8 20:38 * @version: V1.0 <描述当前版本功能> */ class OilPaintFilter(private val ksize:Int = 10,private val intensity:Int = 40): BaseFilter() { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(size) var index = 0 val subradius: Int = this.ksize / 2 val intensityCount = IntArray(intensity + 1) val ravg = IntArray(intensity + 1) val gavg = IntArray(intensity + 1) val bavg = IntArray(intensity + 1) for (i in 0..intensity) { intensityCount[i] = 0 ravg[i] = 0 gavg[i] = 0 bavg[i] = 0 } for (row in 0 until height) { val ta = 0 var tr = 0 var tg = 0 var tb = 0 for (col in 0 until width) { for (subRow in -subradius..subradius) { for (subCol in -subradius..subradius) { var nrow = row + subRow var ncol = col + subCol if (nrow >= height || nrow < 0) { nrow = 0 } if (ncol >= width || ncol < 0) { ncol = 0 } index = nrow * width + ncol tr = inPixels[index] shr 16 and 0xff tg = inPixels[index] shr 8 and 0xff tb = inPixels[index] and 0xff val curIntensity = (((tr + tg + tb) / 3).toDouble() * intensity / 255.0f).toInt() intensityCount[curIntensity]++ ravg[curIntensity] += tr gavg[curIntensity] += tg bavg[curIntensity] += tb } } // find the max number of same gray level pixel var maxCount = 0 var maxIndex = 0 for (m in intensityCount.indices) { if (intensityCount[m] > maxCount) { maxCount = intensityCount[m] maxIndex = m } } // get average value of the pixel val nr = ravg[maxIndex] / maxCount val ng = gavg[maxIndex] / maxCount val nb = bavg[maxIndex] / maxCount index = row * width + col outPixels[index] = ta shl 24 or (nr shl 16) or (ng shl 8) or nb // post clear values for next pixel for (i in 0..intensity) { intensityCount[i] = 0 ravg[i] = 0 gavg[i] = 0 bavg[i] = 0 } } } setRGB(dstImage, 0, 0, width, height, outPixels) return dstImage } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/PointillizeFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.math.mixColors import cn.netdiscovery.monica.imageprocess.math.smoothStep import cn.netdiscovery.monica.imageprocess.utils.clamp /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.PointillizeFilter * @author: Tony Shen * @date: 2025/3/23 17:59 * @version: V1.0 <描述当前版本功能> */ class PointillizeFilter(private val edgeThickness:Float = 0.4f, private val fuzziness:Float = 0.1f, override var scale:Float = 16f, override var randomness:Float = 0f, override var gridType:Int = HEXAGONAL): CellularFilter(scale = scale, randomness = randomness, gridType = gridType) { private var fadeEdges = false private var edgeColor = 0xff000000.toInt() override fun getPixel(x: Int, y: Int, inPixels: IntArray, width: Int, height: Int): Int { var nx = m00 * x + m01 * y var ny = m10 * x + m11 * y nx /= scale ny /= scale * stretch nx += 1000f ny += 1000f // Reduce artifacts around 0,0 var f = evaluate(nx, ny) val f1 = results[0]!!.distance var srcx: Int = clamp(((results[0]!!.x - 1000) * scale).toInt(), 0, width - 1) var srcy: Int = clamp(((results[0]!!.y - 1000) * scale).toInt(), 0, height - 1) var v = inPixels[srcy * width + srcx] if (fadeEdges) { val f2 = results[1]!!.distance srcx = clamp(((results[1]!!.x - 1000) * scale).toInt(), 0, width - 1) srcy = clamp(((results[1]!!.y - 1000) * scale).toInt(), 0, height - 1) val v2 = inPixels[srcy * width + srcx] v = mixColors(0.5f * f1 / f2, v, v2) } else { f = 1 - smoothStep(edgeThickness, edgeThickness + fuzziness, f1) v = mixColors(f, edgeColor, v) } return v } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/PosterizeFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.PointFilter /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.PosterizeFilter * @author: Tony Shen * @date: 2025/3/12 13:47 * @version: V1.0 <描述当前版本功能> */ class PosterizeFilter(private val numLevels:Int = 6): PointFilter() { private var levels: IntArray = IntArray(256) init{ if (numLevels != 1) for (i in 0..255) levels[i] = 255 * (numLevels * i / 256) / (numLevels - 1) } override fun filterRGB(x: Int, y: Int, rgb: Int): Int { val a = rgb and 0xff000000.toInt() var r = (rgb shr 16) and 0xff var g = (rgb shr 8) and 0xff var b = rgb and 0xff r = levels[r] g = levels[g] b = levels[b] return a or (r shl 16) or (g shl 8) or b } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/RippleFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.TransformFilter import cn.netdiscovery.monica.imageprocess.math.Noise import cn.netdiscovery.monica.imageprocess.math.mod import cn.netdiscovery.monica.imageprocess.math.triangle import java.awt.Rectangle import kotlin.math.sin /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.RippleFilter * @author: Tony Shen * @date: 2025/3/10 11:06 * @version: V1.0 <描述当前版本功能> */ class RippleFilter(private val xAmplitude:Float = 5.0f, private val yAmplitude:Float = 0.0f, private val xWavelength:Float = 16.0f, private val yWavelength:Float = 16.0f, private val waveType:Int = 0): TransformFilter() { /** * Sine wave ripples. */ val SINE: Int = 0 /** * Sawtooth wave ripples. */ val SAWTOOTH: Int = 1 /** * Triangle wave ripples. */ val TRIANGLE: Int = 2 /** * Noise ripples. */ val NOISE: Int = 3 override fun transformSpace(rect: Rectangle) { if (edgeAction == ZERO) { rect.x -= xAmplitude.toInt() rect.width += (2 * xAmplitude).toInt() rect.y -= yAmplitude.toInt() rect.height += (2 * yAmplitude).toInt() } } override fun transformInverse(x: Int, y: Int, out: FloatArray) { val nx = y.toFloat() / xWavelength val ny = x.toFloat() / yWavelength val fx: Float val fy: Float when (waveType) { SINE -> { fx = sin(nx.toDouble()).toFloat() fy = sin(ny.toDouble()).toFloat() } SAWTOOTH -> { fx = mod(nx, 1.0f) fy = mod(ny, 1.0f) } TRIANGLE -> { fx = triangle(nx) fy = triangle(ny) } NOISE -> { fx = Noise.noise1(nx) fy = Noise.noise1(ny) } else -> { fx = sin(nx.toDouble()).toFloat() fy = sin(ny.toDouble()).toFloat() } } out[0] = x + xAmplitude * fx out[1] = y + yAmplitude * fy } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/SepiaToneFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.SepiaToneFilter * @author: Tony Shen * @date: 2024/5/1 11:09 * @version: V1.0 SepiaTone 滤镜, 老照片特效 */ class SepiaToneFilter : BaseFilter() { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(width * height) var index = 0 for (row in 0 until height) { var ta = 0 var tr = 0 var tg = 0 var tb = 0 for (col in 0 until width) { index = row * width + col ta = inPixels[index] shr 24 and 0xff tr = inPixels[index] shr 16 and 0xff tg = inPixels[index] shr 8 and 0xff tb = inPixels[index] and 0xff val fr = colorBlend(noise(), tr * 0.393 + tg * 0.769 + tb * 0.189, tr).toInt() val fg = colorBlend(noise(), tr * 0.349 + tg * 0.686 + tb * 0.168, tg).toInt() val fb = colorBlend(noise(), tr * 0.272 + tg * 0.534 + tb * 0.131, tb).toInt() outPixels[index] = ta shl 24 or (clamp(fr) shl 16) or (clamp(fg) shl 8) or clamp(fb) } } setRGB(dstImage, 0, 0, width, height, outPixels) return dstImage } private fun noise(): Double = Math.random() * 0.5 + 0.5 private fun colorBlend(scale: Double, dest: Double, src: Int): Double { return scale * dest + (1.0 - scale) * src } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/SmearFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.WholeImageFilter import cn.netdiscovery.monica.imageprocess.math.mixColors import java.awt.Rectangle import java.util.* import kotlin.math.abs import kotlin.math.cos import kotlin.math.sin /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.SmearFilter * @author: Tony Shen * @date: 2025/3/21 16:27 * @version: V1.0 <描述当前版本功能> */ class SmearFilter(private var angle:Float = 0f, private var density:Float = 0.5f, private var distance:Int = 8, private var shape: Int = CIRCLES, private var mix:Float = 0.5f): WholeImageFilter() { companion object { val CROSSES: Int = 0 val LINES: Int = 1 val CIRCLES: Int = 2 val SQUARES: Int = 3 } private var seed: Long = 567 private var randomGenerator = Random() private var background = false override fun filterPixels(width: Int, height: Int, inPixels: IntArray, transformedSpace: Rectangle): IntArray { val outPixels = IntArray(width * height) randomGenerator.setSeed(seed) val sinAngle = sin(angle.toDouble()).toFloat() val cosAngle = cos(angle.toDouble()).toFloat() var i = 0 val numShapes: Int for (y in 0.. { //Crosses numShapes = (2 * density * width * height / (distance + 1)).toInt() i = 0 while (i < numShapes) { val x = (randomGenerator.nextInt() and 0x7fffffff) % width val y = (randomGenerator.nextInt() and 0x7fffffff) % height val length = randomGenerator.nextInt() % distance + 1 val rgb = inPixels[y * width + x] var x1 = x - length while (x1 < x + length + 1) { if (x1 >= 0 && x1 < width) { val rgb2 = if (background) -0x1 else outPixels[y * width + x1] outPixels[y * width + x1] = mixColors(mix, rgb2, rgb) } x1++ } var y1 = y - length while (y1 < y + length + 1) { if (y1 >= 0 && y1 < height) { val rgb2 = if (background) -0x1 else outPixels[y1 * width + x] outPixels[y1 * width + x] = mixColors(mix, rgb2, rgb) } y1++ } i++ } } LINES -> { numShapes = (2 * density * width * height / 2).toInt() i = 0 while (i < numShapes) { val sx = (randomGenerator.nextInt() and 0x7fffffff) % width val sy = (randomGenerator.nextInt() and 0x7fffffff) % height val rgb = inPixels[sy * width + sx] val length = (randomGenerator.nextInt() and 0x7fffffff) % distance var dx = (length * cosAngle).toInt() var dy = (length * sinAngle).toInt() val x0 = sx - dx val y0 = sy - dy val x1 = sx + dx val y1 = sy + dy var d: Int val incrE: Int val incrNE: Int val ddx = if (x1 < x0) -1 else 1 val ddy = if (y1 < y0) -1 else 1 dx = x1 - x0 dy = y1 - y0 dx = abs(dx.toDouble()).toInt() dy = abs(dy.toDouble()).toInt() var x = x0 var y = y0 if (x < width && x >= 0 && y < height && y >= 0) { val rgb2 = if (background) -0x1 else outPixels[y * width + x] outPixels[y * width + x] = mixColors(mix, rgb2, rgb) } if (abs(dx.toDouble()) > abs(dy.toDouble())) { d = 2 * dy - dx incrE = 2 * dy incrNE = 2 * (dy - dx) while (x != x1) { if (d <= 0) d += incrE else { d += incrNE y += ddy } x += ddx if (x < width && x >= 0 && y < height && y >= 0) { val rgb2 = if (background) -0x1 else outPixels[y * width + x] outPixels[y * width + x] = mixColors(mix, rgb2, rgb) } } } else { d = 2 * dx - dy incrE = 2 * dx incrNE = 2 * (dx - dy) while (y != y1) { if (d <= 0) d += incrE else { d += incrNE x += ddx } y += ddy if (x < width && x >= 0 && y < height && y >= 0) { val rgb2 = if (background) -0x1 else outPixels[y * width + x] outPixels[y * width + x] = mixColors(mix, rgb2, rgb) } } } i++ } } SQUARES, CIRCLES -> { val radius = distance + 1 val radius2 = radius * radius numShapes = (2 * density * width * height / radius).toInt() i = 0 while (i < numShapes) { val sx = (randomGenerator.nextInt() and 0x7fffffff) % width val sy = (randomGenerator.nextInt() and 0x7fffffff) % height val rgb = inPixels[sy * width + sx] var x = sx - radius while (x < sx + radius + 1) { var y = sy - radius while (y < sy + radius + 1) { val f = if (shape === CIRCLES) (x - sx) * (x - sx) + (y - sy) * (y - sy) else 0 if (x >= 0 && x < width && y >= 0 && y < height && f <= radius2) { val rgb2 = if (background) -0x1 else outPixels[y * width + x] outPixels[y * width + x] = mixColors(mix, rgb2, rgb) } y++ } x++ } i++ } } } return outPixels } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/SolarizeFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.TransferFilter /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.SolarizeFilter * @author: Tony Shen * @date: 2025/3/19 20:44 * @version: V1.0 <描述当前版本功能> */ class SolarizeFilter: TransferFilter() { override fun transferFunction(v: Float): Float { return if (v > 0.5f) 2 * (v - 0.5f) else 2 * (0.5f - v) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/SpotlightFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import java.awt.image.BufferedImage import kotlin.math.sqrt /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.SpotlightFilter * @author: Tony Shen * @date: 2024/4/29 15:23 * @version: V1.0 <描述当前版本功能> */ class SpotlightFilter(private val factor:Int = 1): BaseFilter() { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(width * height) var index = 0 val centerX = width / 2 val centerY = height / 2 val maxDistance = sqrt((centerX * centerX + centerY * centerY).toDouble()) for (row in 0 until height) { var ta = 0 var tr = 0 var tg = 0 var tb = 0 for (col in 0 until width) { index = row * width + col ta = inPixels[index] shr 24 and 0xff tr = inPixels[index] shr 16 and 0xff tg = inPixels[index] shr 8 and 0xff tb = inPixels[index] and 0xff var scale: Double = 1.0 - getDistance(centerX, centerY, col, row) / maxDistance for (i in 0 until factor) { scale = scale * scale } tr = (scale * tr).toInt() tg = (scale * tg).toInt() tb = (scale * tb).toInt() outPixels[index] = ta shl 24 or (tr shl 16) or (tg shl 8) or tb } } setRGB(dstImage, 0, 0, width, height, outPixels) return dstImage } private fun getDistance(centerX: Int, centerY: Int, px: Int, py: Int): Double { val xx = ((centerX - px) * (centerX - px)).toDouble() val yy = ((centerY - py) * (centerY - py)).toDouble() return sqrt(xx + yy).toInt().toDouble() } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/StrokeAreaFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.StrokeAreaFilter * @author: Tony Shen * @date: 2024/5/25 22:00 * @version: V1.0 <描述当前版本功能> */ class StrokeAreaFilter(private val ksize:Double = 10.0):BaseFilter() { private val d02 = (150 * 150).toDouble() override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(width * height) var index = 0 var index2 = 0 val semiRow = (ksize / 2).toInt() val semiCol = (ksize / 2).toInt() var newX: Int var newY: Int // initialize the color RGB array with zero... val rgb = IntArray(3) val rgb2 = IntArray(3) for (i in rgb.indices) { rgb2[i] = 0 rgb[i] = rgb2[i] } // start the algorithm process here for (row in 0 until height) { var ta = 0 for (col in 0 until width) { index = row * width + col ta = inPixels[index] shr 24 and 0xff rgb[0] = inPixels[index] shr 16 and 0xff rgb[1] = inPixels[index] shr 8 and 0xff rgb[2] = inPixels[index] and 0xff /* adjust region to fit in source image */ // color difference and moment Image var moment = 0.0 for (subRow in -semiRow..semiRow) { for (subCol in -semiCol..semiCol) { newY = row + subRow newX = col + subCol if (newY < 0) { newY = 0 } if (newX < 0) { newX = 0 } if (newY >= height) { newY = height - 1 } if (newX >= width) { newX = width - 1 } index2 = newY * width + newX rgb2[0] = inPixels[index2] shr 16 and 0xff // red rgb2[1] = inPixels[index2] shr 8 and 0xff // green rgb2[2] = inPixels[index2] and 0xff // blue moment += colorDiff(rgb, rgb2) } } // calculate the output pixel value. val outPixelValue: Int = clamp((255.0 * moment / (ksize * ksize)).toInt()) outPixels[index] = ta shl 24 or (outPixelValue shl 16) or (outPixelValue shl 8) or outPixelValue } } setRGB(dstImage, 0, 0, width, height, outPixels) return dstImage } private fun colorDiff(rgb1: IntArray, rgb2: IntArray): Double { // (1-(d/d0)^2)^2 val d2: Double val r2: Double d2 = colorDistance(rgb1, rgb2) if (d2 >= d02) return 0.0 r2 = d2 / d02 return (1.0 - r2) * (1.0 - r2) } private fun colorDistance(rgb1: IntArray, rgb2: IntArray): Double { val dr: Int val dg: Int val db: Int dr = rgb1[0] - rgb2[0] dg = rgb1[1] - rgb2[1] db = rgb1[2] - rgb2[2] return (dr * dr + dg * dg + db * db).toDouble() } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/SwimFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.TransformFilter import cn.netdiscovery.monica.imageprocess.math.Noise import kotlin.math.cos import kotlin.math.sin /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.SwimFilter * @author: Tony Shen * @date: 2025/3/19 19:52 * @version: V1.0 <描述当前版本功能> */ class SwimFilter(private val scale:Float = 32f, private val stretch:Float = 1.0f, private val angle:Float = 0f, private val amount:Float = 1.0f, private val turbulence:Float = 1.0f, private val time:Float = 0.0f): TransformFilter() { private var m00 = 1.0f private var m01 = 0.0f private var m10 = 0.0f private var m11 = 1.0f init { val cos = cos(angle.toDouble()).toFloat() val sin = sin(angle.toDouble()).toFloat() m00 = cos m01 = sin m10 = -sin m11 = cos } override fun transformInverse(x: Int, y: Int, out: FloatArray) { var nx = m00 * x + m01 * y var ny = m10 * x + m11 * y nx /= scale ny /= scale * stretch if (turbulence == 1.0f) { out[0] = x + amount * Noise.noise3(nx + 0.5f, ny, time) out[1] = y + amount * Noise.noise3(nx, ny + 0.5f, time) } else { out[0] = x + amount * Noise.turbulence3(nx + 0.5f, ny, turbulence, time) out[1] = y + amount * Noise.turbulence3(nx, ny + 0.5f, turbulence, time) } } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/VignetteFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.Color import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.VignetteFilter * @author: Tony Shen * @date: 2024/5/29 12:50 * @version: V1.0 <描述当前版本功能> */ class VignetteFilter( private val fade:Int = 35, private val vignetteWidth:Int = 50 ): BaseFilter() { private val vignetteColor: Color = Color.black override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(width * height) var index = 0 for (row in 0 until height) { var ta = 0 var tr = 0 var tg = 0 var tb = 0 for (col in 0 until width) { val dX = Math.min(col, width - col) val dY = Math.min(row, height - row) index = row * width + col ta = inPixels[index] shr 24 and 0xff tr = inPixels[index] shr 16 and 0xff tg = inPixels[index] shr 8 and 0xff tb = inPixels[index] and 0xff if ((dY <= vignetteWidth) and (dX <= vignetteWidth)) { val k = 1 - (dY.coerceAtMost(dX) - vignetteWidth + fade).toDouble() / fade.toDouble() outPixels[index] = superpositionColor(ta, tr, tg, tb, k) continue } if ((dX < vignetteWidth - fade) or (dY < vignetteWidth - fade)) { outPixels[index] = ta shl 24 or (vignetteColor.red.toInt() shl 16) or (vignetteColor.green.toInt() shl 8) or vignetteColor.blue.toInt() } else { if ((dX < vignetteWidth) and (dY > vignetteWidth)) { val k = 1 - (dX - vignetteWidth + fade).toDouble() / fade.toDouble() outPixels[index] = superpositionColor(ta, tr, tg, tb, k) } else { if ((dY < vignetteWidth) and (dX > vignetteWidth)) { val k = 1 - (dY - vignetteWidth + fade).toDouble() / fade.toDouble() outPixels[index] = superpositionColor(ta, tr, tg, tb, k) } else { outPixels[index] = ta shl 24 or (tr shl 16) or (tg shl 8) or tb } } } } } setRGB(dstImage, 0, 0, width, height, outPixels) return dstImage } private fun superpositionColor( ta: Int, tr: Int, tg: Int, tb: Int, k: Double ): Int { var red = tr var green = tg var blue = tb red = (vignetteColor.red * k + red * (1.0 - k)).toInt() green = (vignetteColor.green * k + green * (1.0 - k)).toInt() blue = (vignetteColor.blue * k + blue * (1.0 - k)).toInt() return ta shl 24 or (clamp(red) shl 16) or (clamp(green) shl 8) or clamp(blue) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/WaterFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.TransformFilter import cn.netdiscovery.monica.imageprocess.math.TWO_PI import java.awt.image.BufferedImage import kotlin.math.min import kotlin.math.sin import kotlin.math.sqrt /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.WaterFilter * @author: Tony Shen * @date: 2025/3/24 15:45 * @version: V1.0 <描述当前版本功能> */ class WaterFilter(private val wavelength:Float = 16f, private val amplitude:Float = 10f, private val phase:Float = 0f, private val centreX:Float = 0.5f, private val centreY:Float = 0.5f, private var radius:Float = 50f): TransformFilter() { private var radius2 = 0f private var icentreX = 0f private var icentreY = 0f init { edgeAction = CLAMP } override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { icentreX = width * centreX icentreY = height * centreY if (radius == 0f) radius = min(icentreX.toDouble(), icentreY.toDouble()).toFloat() radius2 = radius * radius return super.doFilter(srcImage, dstImage) } override fun transformInverse(x: Int, y: Int, out: FloatArray) { val dx = x - icentreX val dy = y - icentreY val distance2 = dx * dx + dy * dy if (distance2 > radius2) { out[0] = x.toFloat() out[1] = y.toFloat() } else { val distance = sqrt(distance2.toDouble()).toFloat() var amount = amplitude * sin(distance / wavelength * TWO_PI - phase) amount *= (radius - distance) / radius if (distance != 0f) amount *= wavelength / distance out[0] = x + dx * amount out[1] = y + dy * amount } } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/WhiteImageFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import java.awt.Color import java.awt.image.BufferedImage import kotlin.math.ln /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.WhiteImageFilter * @author: Tony Shen * @date: 2024/5/1 12:27 * @version: V1.0 <描述当前版本功能> */ class WhiteImageFilter(private val beta:Double = 1.1): BaseFilter() { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { // make LUT val lut = IntArray(256) for (i in 0..255) { lut[i] = imageMath(i) } for (row in 0 until height) { for (col in 0 until width) { val rgb = srcImage.getRGB(col,row) var r = rgb and (0x00ff0000 shr 16) var g = rgb and (0x0000ff00 shr 8) var b = rgb and 0x000000ff r = lut[r and 0xff] g = lut[g and 0xff] b = lut[b and 0xff] dstImage.setRGB(col, row, Color(r, g, b).rgb) } } return dstImage } private fun imageMath(gray: Int): Int { val scale = 255 / (ln(255 * (this.beta - 1) + 1) / ln(this.beta)) val p1 = ln(gray * (this.beta - 1) + 1) val np = p1 / ln(this.beta) return (np * scale).toInt() } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/base/BaseFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.base import cn.netdiscovery.monica.imageprocess.BufferedImages import cn.netdiscovery.monica.imageprocess.Transformer import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter * @author: Tony Shen * @date: 2024/4/27 13:32 * @version: V1.0 <描述当前版本功能> */ abstract class BaseFilter: Transformer { protected var width = 0 protected var height = 0 protected var type = 0 protected var size = 0 protected lateinit var inPixels: IntArray override fun transform(image: BufferedImage): BufferedImage { width = image.width height = image.height type = image.type size = width * height inPixels = IntArray(size) getRGB(image, 0, 0, width, height, inPixels) val dstImage = BufferedImages.create(width,height,type) return doFilter(image,dstImage) } abstract fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage /** * A convenience method for getting ARGB pixels from an image. This tries to avoid the performance * penalty of BufferedImage.getRGB unmanaging the image. */ fun getRGB(image: BufferedImage, x: Int, y: Int, width: Int, height: Int, pixels: IntArray?): IntArray { val type = image.type return if (type == BufferedImage.TYPE_INT_ARGB || type == BufferedImage.TYPE_INT_RGB) image.raster.getDataElements( x, y, width, height, pixels ) as IntArray else image.getRGB(x, y, width, height, pixels, 0, width) } /** * A convenience method for setting ARGB pixels in an image. This tries to avoid the performance * penalty of BufferedImage.setRGB unmanaging the image. */ fun setRGB(image: BufferedImage, x: Int, y: Int, width: Int, height: Int, pixels: IntArray) { val type = image.type if (type == BufferedImage.TYPE_INT_ARGB || type == BufferedImage.TYPE_INT_RGB) image.raster.setDataElements( x, y, width, height, pixels ) else image.setRGB(x, y, width, height, pixels, 0, width) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/base/ColorProcessorFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.base import cn.netdiscovery.monica.imageprocess.BufferedImages import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.base.ColorProcessorFilter * @author: Tony Shen * @date: 2024/5/8 19:57 * @version: V1.0 <描述当前版本功能> */ abstract class ColorProcessorFilter:BaseFilter() { protected lateinit var R: ByteArray protected lateinit var G: ByteArray protected lateinit var B: ByteArray override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { R = ByteArray(size) G = ByteArray(size) B = ByteArray(size) getRGB(inPixels,R,G,B) return doColorProcessor(dstImage) } abstract fun doColorProcessor(dstImage: BufferedImage):BufferedImage /** Returns the red, green and blue planes as 3 byte arrays. */ fun getRGB(pixels: IntArray, R: ByteArray, G: ByteArray, B: ByteArray) { var c: Int var r: Int var g: Int var b: Int val length = pixels.size for (i in 0 until length) { c = pixels[i] r = c and 0xff0000 shr 16 g = c and 0xff00 shr 8 b = c and 0xff R[i] = r.toByte() G[i] = g.toByte() B[i] = b.toByte() } } fun setRGB(width: Int, height: Int, pixels: IntArray, R: ByteArray, G: ByteArray, B: ByteArray) { val size = width * height for (i in 0 until size) pixels[i] = -0x1000000 or (R[i].toInt() and 0xff shl 16) or (G[i].toInt() and 0xff shl 8) or (B[i].toInt() and 0xff) } fun setRGB(image: BufferedImage, width: Int, height: Int, pixels: IntArray, R: ByteArray, G: ByteArray, B: ByteArray) { val size = width * height for (i in 0 until size) pixels[i] = -0x1000000 or (R[i].toInt() and 0xff shl 16) or (G[i].toInt() and 0xff shl 8) or (B[i].toInt() and 0xff) setRGB(image, 0, 0, width, height, pixels) } fun toBufferedImage(bitmap:BufferedImage ?= null): BufferedImage { var pixels:IntArray? = IntArray(width * height) val dst = bitmap ?: BufferedImages.create(width,height,type) setRGB(width, height, pixels!!, R, G, B) setRGB(dst, 0, 0, width, height, pixels) pixels = null return dst } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/base/ConvolveFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.base import cn.netdiscovery.monica.imageprocess.utils.clamp import cn.netdiscovery.monica.imageprocess.utils.premultiply import cn.netdiscovery.monica.imageprocess.utils.unpremultiply import java.awt.image.BufferedImage import java.awt.image.Kernel /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.base.ConvolveFilter * @author: Tony Shen * @date: 2024/5/5 17:54 * @version: V1.0 <描述当前版本功能> */ open class ConvolveFilter(private val kernel: Kernel): BaseFilter() { /** * Treat pixels off the edge as zero. */ var ZERO_EDGES = 0 /** * Clamp pixels off the edge to the nearest edge. */ var CLAMP_EDGES = 1 /** * Wrap pixels off the edge to the opposite edge. */ var WRAP_EDGES = 2 /** * Whether to convolve alpha. */ protected var alpha = true /** * Whether to promultiply the alpha before convolving. */ protected var premultiplyAlpha = true constructor():this(FloatArray(9)) { } constructor(matrix: FloatArray): this(Kernel(3, 3, matrix)) { } constructor(rows: Int, cols: Int, matrix: FloatArray) : this(Kernel(cols, rows, matrix)) /** * What do do at the image edges. */ private val edgeAction = CLAMP_EDGES override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val inPixels = IntArray(width * height) val outPixels = IntArray(width * height) getRGB(srcImage, 0, 0, width, height, inPixels) if (premultiplyAlpha) premultiply(inPixels, 0, inPixels.size) convolve(kernel!!, inPixels, outPixels, width, height, alpha, edgeAction) if (premultiplyAlpha) unpremultiply(outPixels, 0, outPixels.size) setRGB(dstImage, 0, 0, width, height, outPixels) return dstImage } /** * Convolve a block of pixels. * @param kernel the kernel * @param inPixels the input pixels * @param outPixels the output pixels * @param width the width * @param height the height * @param edgeAction what to do at the edges */ open fun convolve( kernel: Kernel, inPixels: IntArray, outPixels: IntArray, width: Int, height: Int, edgeAction: Int ) { convolve(kernel, inPixels, outPixels, width, height, true, edgeAction) } /** * Convolve a block of pixels. * @param kernel the kernel * @param inPixels the input pixels * @param outPixels the output pixels * @param width the width * @param height the height * @param alpha include alpha channel * @param edgeAction what to do at the edges */ open fun convolve( kernel: Kernel, inPixels: IntArray, outPixels: IntArray, width: Int, height: Int, alpha: Boolean, edgeAction: Int ) { if (kernel.height == 1) convolveH(kernel, inPixels, outPixels, width, height, alpha, edgeAction) else if (kernel.width == 1) convolveV(kernel, inPixels, outPixels, width, height, alpha, edgeAction) else convolveHV(kernel, inPixels, outPixels, width, height, alpha, edgeAction) } /** * Convolve with a 2D kernel. * @param kernel the kernel * @param inPixels the input pixels * @param outPixels the output pixels * @param width the width * @param height the height * @param alpha include alpha channel * @param edgeAction what to do at the edges */ open fun convolveHV( kernel: Kernel, inPixels: IntArray, outPixels: IntArray, width: Int, height: Int, alpha: Boolean, edgeAction: Int ) { var index = 0 val matrix = kernel.getKernelData(null) val rows = kernel.height val cols = kernel.width val rows2 = rows / 2 val cols2 = cols / 2 for (y in 0 until height) { for (x in 0 until width) { var r = 0f var g = 0f var b = 0f var a = 0f for (row in -rows2..rows2) { val iy = y + row var ioffset: Int ioffset = if (0 <= iy && iy < height) iy * width else if (edgeAction == CLAMP_EDGES) y * width else if (edgeAction == WRAP_EDGES) (iy + height) % height * width else continue val moffset = cols * (row + rows2) + cols2 for (col in -cols2..cols2) { val f = matrix[moffset + col] if (f != 0f) { var ix = x + col if (!(0 <= ix && ix < width)) { ix = if (edgeAction == CLAMP_EDGES) x else if (edgeAction == WRAP_EDGES) (x + width) % width else continue } val rgb = inPixels[ioffset + ix] a += f * (rgb shr 24 and 0xff) r += f * (rgb shr 16 and 0xff) g += f * (rgb shr 8 and 0xff) b += f * (rgb and 0xff) } } } val ia = if (alpha) clamp((a + 0.5).toInt()) else 0xff val ir: Int = clamp((r + 0.5).toInt()) val ig: Int = clamp((g + 0.5).toInt()) val ib: Int = clamp((b + 0.5).toInt()) outPixels[index++] = ia shl 24 or (ir shl 16) or (ig shl 8) or ib } } } /** * Convolve with a kernel consisting of one row. * @param kernel the kernel * @param inPixels the input pixels * @param outPixels the output pixels * @param width the width * @param height the height * @param alpha include alpha channel * @param edgeAction what to do at the edges */ open fun convolveH( kernel: Kernel, inPixels: IntArray, outPixels: IntArray, width: Int, height: Int, alpha: Boolean, edgeAction: Int ) { var index = 0 val matrix = kernel.getKernelData(null) val cols = kernel.width val cols2 = cols / 2 for (y in 0 until height) { val ioffset = y * width for (x in 0 until width) { var r = 0f var g = 0f var b = 0f var a = 0f for (col in -cols2..cols2) { val f = matrix[cols2 + col] if (f != 0f) { var ix = x + col if (ix < 0) { if (edgeAction == CLAMP_EDGES) ix = 0 else if (edgeAction == WRAP_EDGES) ix = (x + width) % width } else if (ix >= width) { if (edgeAction == CLAMP_EDGES) ix = width - 1 else if (edgeAction == WRAP_EDGES) ix = (x + width) % width } val rgb = inPixels[ioffset + ix] a += f * (rgb shr 24 and 0xff) r += f * (rgb shr 16 and 0xff) g += f * (rgb shr 8 and 0xff) b += f * (rgb and 0xff) } } val ia = if (alpha) clamp((a + 0.5).toInt()) else 0xff val ir: Int = clamp((r + 0.5).toInt()) val ig: Int = clamp((g + 0.5).toInt()) val ib: Int = clamp((b + 0.5).toInt()) outPixels[index++] = ia shl 24 or (ir shl 16) or (ig shl 8) or ib } } } /** * Convolve with a kernel consisting of one column. * @param kernel the kernel * @param inPixels the input pixels * @param outPixels the output pixels * @param width the width * @param height the height * @param alpha include alpha channel * @param edgeAction what to do at the edges */ open fun convolveV( kernel: Kernel, inPixels: IntArray, outPixels: IntArray, width: Int, height: Int, alpha: Boolean, edgeAction: Int ) { var index = 0 val matrix = kernel.getKernelData(null) val rows = kernel.height val rows2 = rows / 2 for (y in 0 until height) { for (x in 0 until width) { var r = 0f var g = 0f var b = 0f var a = 0f for (row in -rows2..rows2) { val iy = y + row var ioffset: Int ioffset = if (iy < 0) { if (edgeAction == CLAMP_EDGES) 0 else if (edgeAction == WRAP_EDGES) (y + height) % height * width else iy * width } else if (iy >= height) { if (edgeAction == CLAMP_EDGES) (height - 1) * width else if (edgeAction == WRAP_EDGES) (y + height) % height * width else iy * width } else iy * width val f = matrix[row + rows2] if (f != 0f) { val rgb = inPixels[ioffset + x] a += f * (rgb shr 24 and 0xff) r += f * (rgb shr 16 and 0xff) g += f * (rgb shr 8 and 0xff) b += f * (rgb and 0xff) } } val ia = if (alpha) clamp((a + 0.5).toInt()) else 0xff val ir: Int = clamp((r + 0.5).toInt()) val ig: Int = clamp((g + 0.5).toInt()) val ib: Int = clamp((b + 0.5).toInt()) outPixels[index++] = ia shl 24 or (ir shl 16) or (ig shl 8) or ib } } } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/base/PointFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.base import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.base.PointFilter * @author: Tony Shen * @date: 2025/3/11 21:00 * @version: V1.0 <描述当前版本功能> */ abstract class PointFilter: BaseFilter() { protected var canFilterIndexColorModel: Boolean = false override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { setDimensions(width, height) val outPixels = IntArray(width * height) var index = 0 for (row in 0 until height) { for (col in 0 until width) { index = row * width + col outPixels[index] = filterRGB(col, row, inPixels[index]) } } setRGB(dstImage, 0, 0, width, height, outPixels) return dstImage } open fun setDimensions(width: Int, height: Int) { } abstract fun filterRGB(x: Int, y: Int, rgb: Int): Int } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/base/TransferFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.base import cn.netdiscovery.monica.imageprocess.utils.clamp /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.base.TransferFilter * @author: Tony Shen * @date: 2025/3/12 17:58 * @version: V1.0 <描述当前版本功能> */ abstract class TransferFilter : PointFilter(){ protected lateinit var rTable: IntArray protected lateinit var gTable: IntArray protected lateinit var bTable: IntArray init { canFilterIndexColorModel = true } private fun makeTable(): IntArray { val table = IntArray(256) for (i in 0..255) { table[i] = clamp((255 * transferFunction(i / 255.0f)).toInt()) } return table } abstract fun transferFunction(v: Float): Float override fun filterRGB(x: Int, y: Int, rgb: Int): Int { rTable = makeTable() gTable = rTable bTable = rTable val a = rgb and 0xff000000.toInt() var r = (rgb shr 16) and 0xff var g = (rgb shr 8) and 0xff var b = rgb and 0xff r = rTable[r] g = gTable[g] b = bTable[b] return a or (r shl 16) or (g shl 8) or b } // fun getLUT(): IntArray { // val lut = IntArray(256) // for (i in 0..255) { // lut[i] = filterRGB(0, 0, (i shl 24) or (i shl 16) or (i shl 8) or i) // } // return lut // } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/base/TransformFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.base import cn.netdiscovery.monica.imageprocess.math.bilinearInterpolate import cn.netdiscovery.monica.imageprocess.utils.clamp import cn.netdiscovery.monica.imageprocess.math.mod import java.awt.Rectangle import java.awt.image.BufferedImage import kotlin.math.floor /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.base.TransformFilter * @author: Tony Shen * @date: 2025/3/8 13:45 * @version: V1.0 <描述当前版本功能> */ abstract class TransformFilter: BaseFilter() { /** * Treat pixels off the edge as zero. */ val ZERO: Int = 0 /** * Clamp pixels to the image edges. */ val CLAMP: Int = 1 /** * Wrap pixels off the edge onto the oppsoite edge. */ val WRAP: Int = 2 /** * Clamp pixels RGB to the image edges, but zero the alpha. This prevents gray borders on your image. */ val RGB_CLAMP: Int = 3 /** * Use nearest-neighbout interpolation. */ val NEAREST_NEIGHBOUR: Int = 0 /** * Use bilinear interpolation. */ val BILINEAR: Int = 1 /** * The action to take for pixels off the image edge. */ protected var edgeAction: Int = RGB_CLAMP /** * The type of interpolation to use. */ protected var interpolation: Int = BILINEAR /** * The output image rectangle. */ protected var transformedSpace: Rectangle? = null /** * The input image rectangle. */ protected var originalSpace: Rectangle? = null /** * Inverse transform a point. This method needs to be overriden by all subclasses. * @param x the X position of the pixel in the output image * @param y the Y position of the pixel in the output image * @param out the position of the pixel in the input image */ abstract fun transformInverse(x: Int, y: Int, out: FloatArray) /** * Forward transform a rectangle. Used to determine the size of the output image. * @param rect the rectangle to transform */ protected open fun transformSpace(rect: Rectangle) { } override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { var dst = dstImage val width = srcImage.width val height = srcImage.height val type = srcImage.type val srcRaster = srcImage.raster originalSpace = Rectangle(0, 0, width, height) transformedSpace = Rectangle(0, 0, width, height) transformSpace(transformedSpace!!) // val dstRaster = dst.raster val inPixels: IntArray = getRGB(srcImage, 0, 0, width, height, null) if (interpolation == NEAREST_NEIGHBOUR) return filterPixelsNN(dst, width, height, inPixels, transformedSpace!!) val srcWidth = width val srcHeight = height val srcWidth1 = width - 1 val srcHeight1 = height - 1 val outWidth = transformedSpace!!.width val outHeight = transformedSpace!!.height val index = 0 val outPixels = IntArray(outWidth) val outX = transformedSpace!!.x val outY = transformedSpace!!.y val out = FloatArray(2) for (y in 0 until outHeight) { for (x in 0 until outWidth) { transformInverse(outX + x, outY + y, out) val srcX = floor(out[0].toDouble()).toInt() val srcY = floor(out[1].toDouble()).toInt() val xWeight = out[0] - srcX val yWeight = out[1] - srcY var nw: Int var ne: Int var sw: Int var se: Int if (srcX >= 0 && srcX < srcWidth1 && srcY >= 0 && srcY < srcHeight1) { // Easy case, all corners are in the image val i = srcWidth * srcY + srcX nw = inPixels[i] ne = inPixels[i + 1] sw = inPixels[i + srcWidth] se = inPixels[i + srcWidth + 1] } else { // Some of the corners are off the image nw = getPixel(inPixels, srcX, srcY, srcWidth, srcHeight) ne = getPixel(inPixels, srcX + 1, srcY, srcWidth, srcHeight) sw = getPixel(inPixels, srcX, srcY + 1, srcWidth, srcHeight) se = getPixel(inPixels, srcX + 1, srcY + 1, srcWidth, srcHeight) } outPixels[x] = bilinearInterpolate(xWeight, yWeight, nw, ne, sw, se) } setRGB(dst, 0, y, transformedSpace!!.width, 1, outPixels) } return dst } private fun getPixel(pixels: IntArray, x: Int, y: Int, width: Int, height: Int): Int { if (x < 0 || x >= width || y < 0 || y >= height) { return when (edgeAction) { ZERO -> 0 WRAP -> pixels[mod(y, height) * width + mod(x, width)] CLAMP -> pixels[clamp(y, 0, height - 1) * width + clamp(x, 0, width - 1)] RGB_CLAMP -> pixels[clamp(y, 0, height - 1) * width + clamp(x, 0, width - 1)] and 0x00ffffff else -> 0 } } return pixels[y * width + x] } protected fun filterPixelsNN( dst: BufferedImage, width: Int, height: Int, inPixels: IntArray, transformedSpace: Rectangle ): BufferedImage { val srcWidth = width val srcHeight = height val outWidth = transformedSpace.width val outHeight = transformedSpace.height var srcX: Int var srcY: Int val outPixels = IntArray(outWidth) val outX = transformedSpace.x val outY = transformedSpace.y val rgb = IntArray(4) val out = FloatArray(2) for (y in 0 until outHeight) { for (x in 0 until outWidth) { transformInverse(outX + x, outY + y, out) srcX = out[0].toInt() srcY = out[1].toInt() // int casting rounds towards zero, so we check out[0] < 0, not srcX < 0 if (out[0] < 0 || srcX >= srcWidth || out[1] < 0 || srcY >= srcHeight) { var p = when (edgeAction) { ZERO -> 0 WRAP -> inPixels[mod(srcY, srcHeight) * srcWidth + mod(srcX, srcWidth)] CLAMP -> inPixels[clamp(srcY, 0, srcHeight - 1) * srcWidth + clamp(srcX, 0, srcWidth - 1)] RGB_CLAMP -> inPixels[clamp(srcY, 0, srcHeight - 1) * srcWidth + clamp(srcX, 0, srcWidth - 1)] and 0x00ffffff else -> 0 } outPixels[x] = p } else { val i = srcWidth * srcY + srcX rgb[0] = inPixels[i] outPixels[x] = inPixels[i] } } setRGB(dst, 0, y, transformedSpace.width, 1, outPixels) } return dst } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/base/WholeImageFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.base import java.awt.Rectangle import java.awt.image.BufferedImage import java.awt.image.WritableRaster /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.base.WholeImageFilter * @author: Tony Shen * @date: 2025/3/20 10:51 * @version: V1.0 <描述当前版本功能> */ abstract class WholeImageFilter:BaseFilter() { /** * The output image bounds. */ protected lateinit var transformedSpace: Rectangle /** * The input image bounds. */ protected lateinit var originalSpace: Rectangle override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val srcRaster: WritableRaster = srcImage.raster originalSpace = Rectangle(0, 0, width, height) transformedSpace = Rectangle(0, 0, width, height) transformSpace(transformedSpace) val dstRaster: WritableRaster = dstImage.raster var inPixels = getRGB(srcImage, 0, 0, width, height, null) inPixels = filterPixels(width, height, inPixels, transformedSpace) setRGB(dstImage, 0, 0, transformedSpace.width, transformedSpace.height, inPixels) return dstImage } /** * Calculate output bounds for given input bounds. * @param rect input and output rectangle */ open fun transformSpace(rect: Rectangle) { } /** * Actually filter the pixels. * @param width the image width * @param height the image height * @param inPixels the image pixels * @param transformedSpace the output bounds * @return the output pixels */ protected abstract fun filterPixels( width: Int, height: Int, inPixels: IntArray, transformedSpace: Rectangle ): IntArray } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/blur/AverageFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.blur import cn.netdiscovery.monica.imageprocess.filter.base.ConvolveFilter /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.blur.AverageFilter * @author: Tony Shen * @date: 2024/5/5 19:17 * @version: V1.0 <描述当前版本功能> */ class AverageFilter: ConvolveFilter(matrix) { companion object { private val matrix = floatArrayOf( 0.1f, 0.1f, 0.1f, 0.1f, 0.2f, 0.1f, 0.1f, 0.1f, 0.1f ) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/blur/BoxBlurFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.blur import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.blur.BoxBlurFilter * @author: Tony Shen * @date: 2024/4/27 13:36 * @version: V1.0 <描述当前版本功能> */ class BoxBlurFilter(private val hRadius: Int =5, private val vRadius:Int=5, private val iterations:Int=1): BaseFilter() { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { var outPixels = IntArray(size) for (i in 0 until iterations) { blur( inPixels, outPixels, width, height, hRadius ) blur( outPixels, inPixels, height, width, vRadius ) } setRGB(dstImage, 0, 0, width, height, inPixels) return dstImage } private fun blur(`in`: IntArray, out: IntArray, width: Int, height: Int, radius: Int) { val widthMinus1 = width - 1 val tableSize = 2 * radius + 1 val divide = IntArray(256 * tableSize) // the value scope will be 0 to 255, and number of 0 is table size // will get means from index not calculate result again since // color value must be between 0 and 255. for (i in 0 until 256 * tableSize) divide[i] = i / tableSize var inIndex = 0 // for (y in 0 until height) { var outIndex = y var ta = 0 var tr = 0 var tg = 0 var tb = 0 // ARGB -> prepare for the alpha, red, green, blue color value. for (i in -radius..radius) { val rgb = `in`[inIndex + clamp(i, 0, width - 1)] // read input pixel data here. table size data. ta += rgb shr 24 and 0xff tr += rgb shr 16 and 0xff tg += rgb shr 8 and 0xff tb += rgb and 0xff } for (x in 0 until width) { // get output pixel data. out[outIndex] = divide[ta] shl 24 or (divide[tr] shl 16) or (divide[tg] shl 8) or divide[tb] // calculate the output data. var i1 = x + radius + 1 if (i1 > widthMinus1) i1 = widthMinus1 var i2 = x - radius if (i2 < 0) i2 = 0 val rgb1 = `in`[inIndex + i1] val rgb2 = `in`[inIndex + i2] ta += (rgb1 shr 24 and 0xff) - (rgb2 shr 24 and 0xff) tr += (rgb1 and 0xff0000) - (rgb2 and 0xff0000) shr 16 tg += (rgb1 and 0xff00) - (rgb2 and 0xff00) shr 8 tb += (rgb1 and 0xff) - (rgb2 and 0xff) outIndex += height // per column or per row as cycle... } inIndex += width // next (i+ column number * n, n=1....n-1) } } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/blur/FastBlur2D.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.blur import cn.netdiscovery.monica.imageprocess.IntIntegralImage import cn.netdiscovery.monica.imageprocess.filter.base.ColorProcessorFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import com.safframework.kotlin.coroutines.asyncInBackground import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.blur.FastBlur2D * @author: Tony Shen * @date: 2024/6/22 22:35 * @version: V1.0 <描述当前版本功能> */ class FastBlur2D(private val ksize:Int = 5) : ColorProcessorFilter() { override fun doColorProcessor(dstImage: BufferedImage): BufferedImage { val radius = ksize / 2 runBlocking { listOf( asyncInBackground { var output:ByteArray? = ByteArray(size) val ii = IntIntegralImage() System.arraycopy(R, 0, output, 0, size) ii.setImage(R) ii.calculate(width, height) processSingleChannel(width, height,radius, ii, output!!) System.arraycopy(output, 0, R, 0, size) output = null }, asyncInBackground { var output:ByteArray? = ByteArray(size) val ii = IntIntegralImage() System.arraycopy(G, 0, output, 0, size) ii.setImage(G) ii.calculate(width, height) processSingleChannel(width, height,radius, ii, output!!) System.arraycopy(output, 0, G, 0, size) output = null }, asyncInBackground { var output:ByteArray? = ByteArray(size) val ii = IntIntegralImage() System.arraycopy(B, 0, output, 0, size) ii.setImage(B) ii.calculate(width, height) processSingleChannel(width, height,radius, ii, output!!) System.arraycopy(output, 0, B, 0, size) output = null } ).awaitAll() } return toBufferedImage(dstImage) } private fun processSingleChannel(w: Int, h: Int, radius:Int, ii: IntIntegralImage, output: ByteArray) { var x2 = 0 var y2 = 0 var x1 = 0 var y1 = 0 var cx = 0 var cy = 0 for (row in 0 until h + radius) { y2 = if (row + 1 > h) h else row + 1 y1 = if (row - ksize < 0) 0 else row - ksize for (col in 0 until w + radius) { x2 = if (col + 1 > w) w else col + 1 x1 = if (col - ksize < 0) 0 else col - ksize cx = if (col - radius < 0) 0 else col - radius cy = if (row - radius < 0) 0 else row - radius val num = (x2 - x1) * (y2 - y1) val s = ii.getBlockSum(x1, y1, x2, y2) output[cy * w + cx] = clamp(s / num).toByte() } } } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/blur/GaussianFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.blur import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage import java.awt.image.Kernel import kotlin.math.PI import kotlin.math.ceil import kotlin.math.exp /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.blur.GaussianFilter * @author: Tony Shen * @date: 2024/4/29 17:40 * @version: V1.0 <描述当前版本功能> */ open class GaussianFilter(open val radius:Float = 5.0f): BaseFilter() { /** * Treat pixels off the edge as zero. */ var ZERO_EDGES = 0 /** * Clamp pixels off the edge to the nearest edge. */ var CLAMP_EDGES = 1 /** * Wrap pixels off the edge to the opposite edge. */ var WRAP_EDGES = 2 /** * Whether to convolve alpha. */ protected var alpha = true /** * Whether to promultiply the alpha before convolving. */ protected var premultiplyAlpha = true /** * The convolution kernel. */ protected var kernel: Kernel init { kernel = makeKernel(radius) } override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(width * height) if ( radius > 0 ) { convolveAndTranspose(kernel, inPixels, outPixels, width, height, alpha, alpha && premultiplyAlpha, false, CLAMP_EDGES) convolveAndTranspose(kernel, outPixels, inPixels, height, width, alpha, false, alpha && premultiplyAlpha, CLAMP_EDGES) } setRGB(dstImage, 0, 0, width, height, inPixels) return dstImage } /** * Blur and transpose a block of ARGB pixels. * @param kernel the blur kernel * @param inPixels the input pixels * @param outPixels the output pixels * @param width the width of the pixel array * @param height the height of the pixel array * @param alpha whether to blur the alpha channel * @param edgeAction what to do at the edges */ fun convolveAndTranspose( kernel: Kernel, inPixels: IntArray, outPixels: IntArray, width: Int, height: Int, alpha: Boolean, premultiply: Boolean, unpremultiply: Boolean, edgeAction: Int ) { val matrix = kernel.getKernelData(null) val cols = kernel.width val cols2 = cols / 2 for (y in 0 until height) { var index = y val ioffset = y * width for (x in 0 until width) { var r = 0f var g = 0f var b = 0f var a = 0f for (col in -cols2..cols2) { val f = matrix[cols2 + col] if (f != 0f) { var ix = x + col if (ix < 0) { if (edgeAction == CLAMP_EDGES) ix = 0 else if (edgeAction == WRAP_EDGES) ix = (x + width) % width } else if (ix >= width) { if (edgeAction == CLAMP_EDGES) ix = width - 1 else if (edgeAction == WRAP_EDGES) ix = (x + width) % width } val rgb = inPixels[ioffset + ix] val pa = rgb shr 24 and 0xff var pr = rgb shr 16 and 0xff var pg = rgb shr 8 and 0xff var pb = rgb and 0xff if (premultiply) { val a255 = pa * (1.0f / 255.0f) pr = (pr * a255).toInt() pg = (pg * a255).toInt() pb = (pb * a255).toInt() } a += f * pa r += f * pr g += f * pg b += f * pb } } if (unpremultiply && a != 0f && a != 255f) { val f = 255.0f / a r *= f g *= f b *= f } val ia = if (alpha) clamp((a + 0.5).toInt()) else 0xff val ir: Int = clamp((r + 0.5).toInt()) val ig: Int = clamp((g + 0.5).toInt()) val ib: Int = clamp((b + 0.5).toInt()) outPixels[index] = ia shl 24 or (ir shl 16) or (ig shl 8) or ib index += height } } } private fun makeKernel(radius: Float): Kernel { val r = ceil(radius.toDouble()).toInt() val rows = r * 2 + 1 val matrix = FloatArray(rows) val sigma = radius / 3 val sigma22 = 2 * sigma * sigma val sigmaPi2: Float = 2 * PI.toFloat() * sigma val sqrtSigmaPi2 = Math.sqrt(sigmaPi2.toDouble()).toFloat() val radius2 = radius * radius var total = 0f for ((index, row) in (-r..r).withIndex()) { val distance = (row * row).toFloat() if (distance > radius2) matrix[index] = 0f else matrix[index] = exp((-distance / sigma22).toDouble()).toFloat() / sqrtSigmaPi2 total += matrix[index] } for (i in 0 until rows) matrix[i] /= total return Kernel(rows, 1, matrix) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/blur/LensBlurFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.blur import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import cn.netdiscovery.monica.imageprocess.math.FFT import cn.netdiscovery.monica.imageprocess.math.mod import java.awt.image.BufferedImage import kotlin.math.* /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.blur.LensBlurFilter * @author: Tony Shen * @date: 2025/3/14 15:39 * @version: V1.0 <描述当前版本功能> */ class LensBlurFilter(private val radius:Float = 10f, private val bloom:Float = 2f, private val bloomThreshold:Float = 255f, private val angle:Float = 0f, private val sides:Int = 5): BaseFilter() { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { var rows = 1 var cols = 1 var log2rows = 0 var log2cols = 0 val iradius = ceil(radius.toDouble()).toInt() var tileWidth = 128 var tileHeight = tileWidth // val adjustedWidth = width + iradius * 2 // val adjustedHeight = height + iradius * 2 tileWidth = if (iradius < 32) Math.min(128, width + 2 * iradius) else Math.min(256, width + 2 * iradius) tileHeight = if (iradius < 32) Math.min(128, height + 2 * iradius) else Math.min(256, height + 2 * iradius) while (rows < tileHeight) { rows *= 2 log2rows++ } while (cols < tileWidth) { cols *= 2 log2cols++ } val w = cols val h = rows tileWidth = w tileHeight = h // FIXME - tileWidth, w, 和 cols 始终相同 val fft = FFT(max(log2rows, log2cols)) val rgb = IntArray(w * h) val mask = Array(2) { FloatArray(w * h) } val gb = Array(2) { FloatArray(w * h) } val ar = Array(2) { FloatArray(w * h) } // 创建核函数 val polyAngle = Math.PI / sides val polyScale = 1.0 / Math.cos(polyAngle) val r2 = radius * radius val rangle = Math.toRadians(angle.toDouble()) var total = 0f var i = 0 for (y in 0 until h) { for (x in 0 until w) { val dx:Double = (x - w / 2f).toDouble() val dy:Double = (y - h / 2f).toDouble() var r:Double = dx * dx + dy * dy var f = if (r < r2) 1.0 else 0.0 if (f != 0.0) { r = Math.sqrt(r) f = if (sides != 0) { var a = Math.atan2(dy, dx) + rangle a = mod(a, polyAngle * 2) - polyAngle Math.cos(a) * polyScale } else { 1.0 } f = if (f * r < radius) 1.0 else 0.0 } total += f.toFloat() mask[0][i] = f.toFloat() mask[1][i] = 0f i++ } } // 归一化核函数 i = 0 for (y in 0 until h) { for (x in 0 until w) { mask[0][i] /= total i++ } } fft.transform2D(mask[0], mask[1], w, h, true) var tileY = -iradius while (tileY < height) { var tileX = -iradius while (tileX < width) { // 裁剪 tile 区域到图像范围内 var tx = tileX var ty = tileY var tw = tileWidth var th = tileHeight var fx = 0 var fy = 0 if (tx < 0) { tw += tx fx -= tx tx = 0 } if (ty < 0) { th += ty fy -= ty ty = 0 } if (tx + tw > width) tw = width - tx if (ty + th > height) th = height - ty srcImage.getRGB(tx, ty, tw, th, rgb, fy * w + fx, w) // 根据像素创建浮点数组,图像边界之外的像素使用边缘像素值填充 i = 0 for (y in 0 until h) { val imageY = y + tileY val j = when { imageY < 0 -> fy imageY > height -> fy + th - 1 else -> y } * w for (x in 0 until w) { val imageX = x + tileX val k = when { imageX < 0 -> fx imageX > width -> fx + tw - 1 else -> x } + j ar[0][i] = ((rgb[k] shr 24) and 0xff).toFloat() var rPixel = ((rgb[k] shr 16) and 0xff).toFloat() var gPixel = ((rgb[k] shr 8) and 0xff).toFloat() var bPixel = (rgb[k] and 0xff).toFloat() // Bloom 处理 if (rPixel > bloomThreshold) rPixel *= bloom if (gPixel > bloomThreshold) gPixel *= bloom if (bPixel > bloomThreshold) bPixel *= bloom ar[1][i] = rPixel gb[0][i] = gPixel gb[1][i] = bPixel i++ } } // 转换到频域 fft.transform2D(ar[0], ar[1], cols, rows, true) fft.transform2D(gb[0], gb[1], cols, rows, true) // 将变换后的像素与变换后的核函数相乘 i = 0 for (y in 0 until h) { for (x in 0 until w) { val re = ar[0][i] val im = ar[1][i] val rem = mask[0][i] val imm = mask[1][i] ar[0][i] = re * rem - im * imm ar[1][i] = re * imm + im * rem val reGb = gb[0][i] val imGb = gb[1][i] gb[0][i] = reGb * rem - imGb * imm gb[1][i] = reGb * imm + imGb * rem i++ } } // 逆变换回空域 fft.transform2D(ar[0], ar[1], cols, rows, false) fft.transform2D(gb[0], gb[1], cols, rows, false) // 将频域数据转换回 RGB 像素,并进行象限重新映射 val row_flip = w shr 1 val col_flip = h shr 1 var index = 0 for (y in 0 until w) { val ym = y xor row_flip val yi = ym * cols for (x in 0 until w) { val xm = yi + (x xor col_flip) var a = ar[0][xm].toInt() var r = ar[1][xm].toInt() var g = gb[0][xm].toInt() var b = gb[1][xm].toInt() // 限制 Bloom 后过高的像素值 if (r > 255) r = 255 if (g > 255) g = 255 if (b > 255) b = 255 val argb = (a shl 24) or (r shl 16) or (g shl 8) or b rgb[index++] = argb } } // 将处理后的 tile 裁剪写回输出图像 tx = tileX + iradius ty = tileY + iradius tw = tileWidth - 2 * iradius th = tileHeight - 2 * iradius if (tx + tw > width) tw = width - tx if (ty + th > height) th = height - ty dstImage.setRGB(tx, ty, tw, th, rgb, iradius * w + iradius, w) tileX += tileWidth - 2 * iradius } tileY += tileHeight - 2 * iradius } return dstImage } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/blur/MaximumFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.blur import cn.netdiscovery.monica.imageprocess.filter.base.ColorProcessorFilter import java.awt.image.BufferedImage import java.util.* /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.blur.MaximumFilter * @author: Tony Shen * @date: 2025/3/24 14:41 * @version: V1.0 <描述当前版本功能> */ class MaximumFilter: ColorProcessorFilter() { override fun doColorProcessor(dstImage: BufferedImage): BufferedImage { val numOfPixels = width * height val output: Array = Array(3) { ByteArray(numOfPixels) } val size: Int = 1 * 2 + 1 val total = size * size var r = 0 var g = 0 var b = 0 for (row in 0..= height) height - 1 else roffset) for (j in -1..1) { var coffset: Int = col + j coffset = if (coffset < 0) 0 else (if (coffset >= width) width - 1 else coffset) subpixels[0][index] = R[roffset * width + coffset].toInt() and 0xff subpixels[1][index] = G[roffset * width + coffset].toInt() and 0xff subpixels[2][index] = B[roffset * width + coffset].toInt() and 0xff index++ } } Arrays.sort(subpixels[0]) Arrays.sort(subpixels[1]) Arrays.sort(subpixels[2]) r = subpixels[0][total - 1] g = subpixels[1][total - 1] b = subpixels[2][total - 1] output[0][row * width + col] = r.toByte() output[1][row * width + col] = g.toByte() output[2][row * width + col] = b.toByte() } } R = output[0] G = output[1] B = output[2] return toBufferedImage(dstImage) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/blur/MinimumFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.blur import cn.netdiscovery.monica.imageprocess.filter.base.ColorProcessorFilter import java.awt.image.BufferedImage import java.util.* /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.blur.MinimumFilter * @author: Tony Shen * @date: 2025/3/24 15:31 * @version: V1.0 <描述当前版本功能> */ class MinimumFilter: ColorProcessorFilter() { override fun doColorProcessor(dstImage: BufferedImage): BufferedImage { val numOfPixels = width * height val output: Array = Array(3) { ByteArray(numOfPixels) } val size: Int = 1 * 2 + 1 val total = size * size var r = 0 var g = 0 var b = 0 for (row in 0..= height) height - 1 else roffset) for (j in -1..1) { var coffset: Int = col + j coffset = if (coffset < 0) 0 else (if (coffset >= width) width - 1 else coffset) subpixels[0][index] = R[roffset * width + coffset].toInt() and 0xff subpixels[1][index] = G[roffset * width + coffset].toInt() and 0xff subpixels[2][index] = B[roffset * width + coffset].toInt() and 0xff index++ } } Arrays.sort(subpixels[0]) Arrays.sort(subpixels[1]) Arrays.sort(subpixels[2]) r = subpixels[0][0] g = subpixels[1][0] b = subpixels[2][0] output[0][row * width + col] = r.toByte() output[1][row * width + col] = g.toByte() output[2][row * width + col] = b.toByte() } } R = output[0] G = output[1] B = output[2] return toBufferedImage(dstImage) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/blur/MotionFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.blur import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.blur.MotionFilter * @author: Tony Shen * @date: 2024/5/1 10:52 * @version: V1.0 <描述当前版本功能> */ class MotionFilter(private val distance:Float = 0f,private val angle:Float = 0f,private val zoom:Float = 0.4f): BaseFilter() { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(width * height) var index = 0 val cx = width / 2 val cy = height / 2 // calculate the triangle geometry value val sinAngle = sin(angle / 180.0f * PI).toFloat() val coseAngle = cos(angle / 180.0f * PI).toFloat() // calculate the distance, same as box blur val imageRadius = sqrt((cx * cx + cy * cy).toDouble()).toFloat() val maxDistance: Float = distance + imageRadius * zoom val iteration = maxDistance.toInt() for (row in 0 until height) { var ta = 0 var tr = 0 var tg = 0 var tb = 0 for (col in 0 until width) { var newX = col var count = 0 var newY = row // iterate the source pixels according to distance var m11 = 0.0f var m22 = 0.0f for (i in 0 until iteration) { newX = col newY = row // calculate the operator source pixel if (distance > 0) { newY = Math.floor((newY + i * sinAngle).toDouble()).toInt() newX = Math.floor((newX + i * coseAngle).toDouble()).toInt() } val f = i.toFloat() / iteration if (newX < 0 || newX >= width) { break } if (newY < 0 || newY >= height) { break } // scale the pixels val scale = 1 - zoom * f m11 = cx - cx * scale m22 = cy - cy * scale newY = (newY * scale + m22).toInt() newX = (newX * scale + m11).toInt() // blur the pixels, here count++ val rgb = inPixels[newY * width + newX] ta += rgb shr 24 and 0xff tr += rgb shr 16 and 0xff tg += rgb shr 8 and 0xff tb += rgb and 0xff } // fill the destination pixel with final RGB value if (count == 0) { outPixels[index] = inPixels[index] } else { ta = clamp((ta / count)) tr = clamp((tr / count)) tg = clamp((tg / count)) tb = clamp((tb / count)) outPixels[index] = ta shl 24 or (tr shl 16) or (tg shl 8) or tb } index++ } } setRGB(dstImage, 0, 0, width, height, outPixels) return dstImage } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/blur/VariableBlurFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.blur import cn.netdiscovery.monica.imageprocess.filter.base.BaseFilter import cn.netdiscovery.monica.imageprocess.utils.premultiply import cn.netdiscovery.monica.imageprocess.utils.unpremultiply import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.blur.VariableBlurFilter * @author: Tony Shen * @date: 2024/5/4 15:04 * @version: V1.0 <描述当前版本功能> */ class VariableBlurFilter(private val hRadius: Int =5, private val vRadius:Int=5, private val iterations:Int=1, private val premultiplyAlpha: Boolean = true): BaseFilter() { private var blurMask: BufferedImage? = null set(value) { field = value } override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(width * height) if (premultiplyAlpha) premultiply(inPixels, 0, inPixels.size) for (i in 0 until iterations) { blur(inPixels, outPixels, width, height, hRadius, 1) blur(outPixels, inPixels, height, width, vRadius, 2) } if (premultiplyAlpha) unpremultiply(inPixels, 0, inPixels.size) setRGB(dstImage, 0, 0, width, height, inPixels) return dstImage } fun blur(`in`: IntArray, out: IntArray, width: Int, height: Int, radius: Int, pass: Int) { val widthMinus1 = width - 1 val r = IntArray(width) val g = IntArray(width) val b = IntArray(width) val a = IntArray(width) val mask = IntArray(width) var inIndex = 0 for (y in 0 until height) { var outIndex = y if (blurMask != null) { if (pass == 1) getRGB(blurMask!!, 0, y, width, 1, mask) else getRGB(blurMask!!, y, 0, 1, width, mask) } for (x in 0 until width) { val argb = `in`[inIndex + x] a[x] = (argb shr 24) and 0xff r[x] = (argb shr 16) and 0xff g[x] = (argb shr 8) and 0xff b[x] = argb and 0xff if (x != 0) { a[x] += a[x - 1] r[x] += r[x - 1] g[x] += g[x - 1] b[x] += b[x - 1] } } for (x in 0 until width) { // Get the blur radius at x, y var ra = if (blurMask != null) { if (pass == 1) ((mask[x] and 0xff) * hRadius / 255f).toInt() else ((mask[x] and 0xff) * vRadius / 255f).toInt() } else { if (pass == 1) (blurRadiusAt(x, y, width, height) * hRadius).toInt() else (blurRadiusAt(y, x, height, width) * vRadius).toInt() } val divisor = 2 * ra + 1 var ta = 0 var tr = 0 var tg = 0 var tb = 0 var i1 = x + ra if (i1 > widthMinus1) { val f = i1 - widthMinus1 val l = widthMinus1 ta += (a[l] - a[l - 1]) * f tr += (r[l] - r[l - 1]) * f tg += (g[l] - g[l - 1]) * f tb += (b[l] - b[l - 1]) * f i1 = widthMinus1 } var i2 = x - ra - 1 if (i2 < 0) { ta -= a[0] * i2 tr -= r[0] * i2 tg -= g[0] * i2 tb -= b[0] * i2 i2 = 0 } ta += a[i1] - a[i2] tr += r[i1] - r[i2] tg += g[i1] - g[i2] tb += b[i1] - b[i2] out[outIndex] = ((ta / divisor) shl 24) or ((tr / divisor) shl 16) or ((tg / divisor) shl 8) or (tb / divisor) outIndex += height } inIndex += width } } /** * Override this to get a different blur radius at eahc point. * @param x the x coordinate * @param y the y coordinate * @param width the width of the image * @param height the height of the image * @return the blur radius */ private fun blurRadiusAt(x: Int, y: Int, width: Int, height: Int): Float { return x.toFloat() / width } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/sharpen/LaplaceSharpenFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.sharpen import cn.netdiscovery.monica.imageprocess.filter.base.ConvolveFilter /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.sharpen.LaplaceSharpenFilter * @author: Tony Shen * @date: 2024/5/5 21:05 * @version: V1.0 <描述当前版本功能> */ class LaplaceSharpenFilter: ConvolveFilter(sharpenMatrix) { companion object { private val sharpenMatrix = floatArrayOf( -1f, -1f, -1f, -1f, 9f, -1f, -1f, -1f, -1f, ) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/sharpen/SharpenFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.sharpen import cn.netdiscovery.monica.imageprocess.filter.base.ConvolveFilter /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.sharpen.SharpenFilter * @author: Tony Shen * @date: 2024/5/5 20:56 * @version: V1.0 <描述当前版本功能> */ class SharpenFilter: ConvolveFilter(sharpenMatrix) { companion object { private val sharpenMatrix = floatArrayOf( 0.0f, -0.2f, 0.0f, -0.2f, 1.8f, -0.2f, 0.0f, -0.2f, 0.0f ) } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/filter/sharpen/USMFilter.kt ================================================ package cn.netdiscovery.monica.imageprocess.filter.sharpen import cn.netdiscovery.monica.imageprocess.filter.blur.GaussianFilter import cn.netdiscovery.monica.imageprocess.utils.clamp import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.imageprocess.filter.sharpen.USMFilter * @author: Tony Shen * @date: 2024/5/1 11:17 * @version: V1.0 <描述当前版本功能> */ class USMFilter(override val radius: Float =2f, private val amount: Float = 0.5f, private val threshold:Int =1) : GaussianFilter(radius) { override fun doFilter(srcImage: BufferedImage, dstImage: BufferedImage): BufferedImage { val outPixels = IntArray(width * height) if ( radius > 0 ) { convolveAndTranspose(kernel, inPixels, outPixels, width, height, alpha, alpha && premultiplyAlpha, false, CLAMP_EDGES) convolveAndTranspose(kernel, outPixels, inPixels, height, width, alpha, false, alpha && premultiplyAlpha, CLAMP_EDGES) } getRGB( srcImage,0, 0, width, height, outPixels) val a: Float = 4 * amount var index = 0 for (y in 0 until height) { for (x in 0 until width) { val rgb1 = outPixels[index] var r1 = rgb1 shr 16 and 0xff var g1 = rgb1 shr 8 and 0xff var b1 = rgb1 and 0xff val rgb2 = inPixels[index] val r2 = rgb2 shr 16 and 0xff val g2 = rgb2 shr 8 and 0xff val b2 = rgb2 and 0xff if (Math.abs(r1 - r2) >= threshold) r1 = clamp(((a + 1) * (r1 - r2) + r2).toInt()) if (Math.abs(g1 - g2) >= threshold) g1 = clamp(((a + 1) * (g1 - g2) + g2).toInt()) if (Math.abs(b1 - b2) >= threshold) b1 = clamp(((a + 1) * (b1 - b2) + b2).toInt()) inPixels[index] = rgb1 and 0xff000000.toInt() or (r1 shl 16) or (g1 shl 8) or b1 index++ } } setRGB(dstImage, 0, 0, width, height, inPixels) return dstImage } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/AutumnLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.AutumnLUT * @author: Tony Shen * @date: 2024/6/17 14:05 * @version: V1.0 <描述当前版本功能> */ object AutumnLUT { var AUTUMN_LUT = arrayOf( intArrayOf(254, 0, 0), intArrayOf(255, 1, 1), intArrayOf(255, 1, 0), intArrayOf(255, 2, 0), intArrayOf(255, 5, 0), intArrayOf(255, 5, 0), intArrayOf(255, 7, 0), intArrayOf(255, 7, 0), intArrayOf(255, 8, 1), intArrayOf(255, 9, 2), intArrayOf(254, 10, 0), intArrayOf(255, 11, 1), intArrayOf(254, 12, 0), intArrayOf(255, 13, 1), intArrayOf(255, 13, 0), intArrayOf(255, 15, 0), intArrayOf(255, 16, 0), intArrayOf(254, 17, 0), intArrayOf(255, 18, 0), intArrayOf(255, 18, 0), intArrayOf(255, 20, 0), intArrayOf(255, 21, 0), intArrayOf(254, 22, 0), intArrayOf(255, 23, 1), intArrayOf(254, 24, 0), intArrayOf(255, 25, 1), intArrayOf(255, 27, 0), intArrayOf(255, 27, 0), intArrayOf(255, 27, 0), intArrayOf(255, 28, 0), intArrayOf(255, 30, 0), intArrayOf(255, 30, 0), intArrayOf(255, 32, 0), intArrayOf(255, 33, 0), intArrayOf(254, 34, 0), intArrayOf(254, 34, 0), intArrayOf(255, 35, 0), intArrayOf(255, 36, 1), intArrayOf(255, 38, 0), intArrayOf(255, 39, 0), intArrayOf(255, 40, 0), intArrayOf(254, 41, 0), intArrayOf(254, 43, 0), intArrayOf(254, 43, 0), intArrayOf(255, 44, 0), intArrayOf(255, 45, 0), intArrayOf(254, 46, 0), intArrayOf(255, 47, 1), intArrayOf(254, 48, 0), intArrayOf(255, 49, 0), intArrayOf(255, 50, 0), intArrayOf(255, 50, 0), intArrayOf(255, 52, 0), intArrayOf(255, 52, 0), intArrayOf(255, 54, 0), intArrayOf(255, 55, 0), intArrayOf(255, 56, 1), intArrayOf(255, 57, 2), intArrayOf(254, 58, 0), intArrayOf(254, 58, 0), intArrayOf(254, 60, 0), intArrayOf(255, 61, 0), intArrayOf(255, 62, 0), intArrayOf(255, 63, 0), intArrayOf(255, 64, 0), intArrayOf(254, 65, 0), intArrayOf(255, 66, 0), intArrayOf(255, 67, 1), intArrayOf(255, 68, 0), intArrayOf(255, 68, 0), intArrayOf(254, 70, 0), intArrayOf(255, 71, 1), intArrayOf(255, 73, 0), intArrayOf(255, 73, 0), intArrayOf(255, 75, 0), intArrayOf(255, 75, 0), intArrayOf(255, 76, 0), intArrayOf(255, 76, 0), intArrayOf(255, 78, 0), intArrayOf(255, 79, 1), intArrayOf(255, 80, 0), intArrayOf(255, 81, 0), intArrayOf(254, 82, 0), intArrayOf(254, 83, 1), intArrayOf(255, 85, 0), intArrayOf(254, 85, 0), intArrayOf(255, 87, 0), intArrayOf(255, 87, 0), intArrayOf(255, 88, 0), intArrayOf(255, 88, 0), intArrayOf(255, 90, 0), intArrayOf(255, 91, 0), intArrayOf(255, 93, 0), intArrayOf(255, 93, 0), intArrayOf(254, 94, 0), intArrayOf(255, 95, 1), intArrayOf(255, 97, 0), intArrayOf(255, 97, 0), intArrayOf(255, 98, 1), intArrayOf(255, 98, 1), intArrayOf(254, 100, 0), intArrayOf(255, 101, 1), intArrayOf(255, 102, 0), intArrayOf(255, 103, 1), intArrayOf(255, 103, 0), intArrayOf(255, 104, 0), intArrayOf(255, 105, 0), intArrayOf(255, 107, 0), intArrayOf(255, 108, 0), intArrayOf(255, 109, 0), intArrayOf(255, 110, 1), intArrayOf(255, 110, 1), intArrayOf(254, 112, 0), intArrayOf(255, 113, 1), intArrayOf(255, 115, 0), intArrayOf(255, 115, 0), intArrayOf(255, 116, 0), intArrayOf(255, 117, 0), intArrayOf(255, 119, 0), intArrayOf(255, 119, 0), intArrayOf(255, 120, 2), intArrayOf(255, 120, 2), intArrayOf(255, 122, 1), intArrayOf(255, 122, 1), intArrayOf(254, 124, 0), intArrayOf(255, 125, 1), intArrayOf(255, 126, 0), intArrayOf(255, 127, 0), intArrayOf(255, 128, 0), intArrayOf(254, 129, 0), intArrayOf(255, 130, 1), intArrayOf(255, 130, 1), intArrayOf(255, 133, 0), intArrayOf(255, 133, 0), intArrayOf(255, 134, 1), intArrayOf(255, 134, 1), intArrayOf(254, 136, 0), intArrayOf(255, 137, 1), intArrayOf(255, 139, 0), intArrayOf(255, 139, 0), intArrayOf(255, 140, 0), intArrayOf(255, 141, 0), intArrayOf(255, 142, 1), intArrayOf(255, 142, 1), intArrayOf(255, 144, 0), intArrayOf(255, 144, 0), intArrayOf(255, 146, 1), intArrayOf(255, 147, 2), intArrayOf(255, 149, 1), intArrayOf(255, 149, 1), intArrayOf(255, 150, 0), intArrayOf(255, 151, 0), intArrayOf(255, 152, 0), intArrayOf(254, 153, 0), intArrayOf(254, 155, 0), intArrayOf(254, 155, 0), intArrayOf(255, 156, 0), intArrayOf(255, 156, 0), intArrayOf(255, 158, 1), intArrayOf(255, 159, 2), intArrayOf(254, 160, 0), intArrayOf(255, 161, 1), intArrayOf(255, 162, 0), intArrayOf(255, 163, 0), intArrayOf(255, 165, 0), intArrayOf(255, 165, 0), intArrayOf(255, 167, 0), intArrayOf(255, 167, 0), intArrayOf(255, 168, 1), intArrayOf(255, 168, 1), intArrayOf(255, 170, 1), intArrayOf(255, 171, 2), intArrayOf(255, 173, 1), intArrayOf(255, 173, 1), intArrayOf(255, 173, 0), intArrayOf(255, 175, 0), intArrayOf(255, 177, 0), intArrayOf(254, 177, 0), intArrayOf(255, 178, 0), intArrayOf(255, 179, 1), intArrayOf(255, 180, 0), intArrayOf(255, 180, 0), intArrayOf(255, 182, 0), intArrayOf(255, 183, 1), intArrayOf(254, 184, 0), intArrayOf(255, 185, 1), intArrayOf(254, 186, 0), intArrayOf(255, 187, 0), intArrayOf(255, 188, 0), intArrayOf(255, 188, 0), intArrayOf(255, 190, 0), intArrayOf(255, 190, 0), intArrayOf(255, 192, 0), intArrayOf(255, 193, 0), intArrayOf(254, 194, 0), intArrayOf(255, 195, 1), intArrayOf(255, 195, 0), intArrayOf(255, 196, 1), intArrayOf(255, 197, 0), intArrayOf(255, 199, 0), intArrayOf(255, 200, 0), intArrayOf(254, 201, 0), intArrayOf(254, 203, 0), intArrayOf(254, 203, 0), intArrayOf(255, 204, 0), intArrayOf(255, 205, 0), intArrayOf(254, 206, 0), intArrayOf(255, 207, 1), intArrayOf(254, 208, 0), intArrayOf(255, 209, 1), intArrayOf(255, 210, 0), intArrayOf(255, 210, 0), intArrayOf(255, 212, 0), intArrayOf(255, 212, 0), intArrayOf(255, 214, 0), intArrayOf(255, 214, 0), intArrayOf(255, 216, 1), intArrayOf(255, 217, 2), intArrayOf(255, 219, 1), intArrayOf(255, 219, 1), intArrayOf(254, 220, 0), intArrayOf(255, 221, 0), intArrayOf(255, 222, 0), intArrayOf(255, 223, 0), intArrayOf(255, 224, 0), intArrayOf(254, 225, 0), intArrayOf(255, 226, 0), intArrayOf(255, 226, 0), intArrayOf(255, 228, 0), intArrayOf(255, 229, 0), intArrayOf(254, 230, 0), intArrayOf(255, 231, 1), intArrayOf(254, 232, 0), intArrayOf(255, 233, 0), intArrayOf(255, 235, 0), intArrayOf(255, 235, 0), intArrayOf(255, 236, 0), intArrayOf(255, 236, 0), intArrayOf(255, 238, 0), intArrayOf(255, 239, 1), intArrayOf(255, 240, 0), intArrayOf(255, 241, 0), intArrayOf(255, 243, 1), intArrayOf(255, 243, 1), intArrayOf(255, 243, 0), intArrayOf(255, 244, 0), intArrayOf(255, 246, 0), intArrayOf(255, 247, 0), intArrayOf(255, 248, 0), intArrayOf(254, 249, 0), intArrayOf(254, 251, 0), intArrayOf(255, 252, 0), intArrayOf(255, 253, 0), intArrayOf(255, 253, 0), intArrayOf(254, 254, 0), intArrayOf(255, 255, 1) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/BoneLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.BoneLUT * @author: Tony Shen * @date: 2024/6/17 14:07 * @version: V1.0 <描述当前版本功能> */ object BoneLUT { var BONE_LUT = arrayOf( intArrayOf(0, 0, 0), intArrayOf(1, 1, 1), intArrayOf(2, 2, 2), intArrayOf(3, 3, 3), intArrayOf(4, 4, 4), intArrayOf(4, 4, 4), intArrayOf(5, 5, 7), intArrayOf(6, 6, 8), intArrayOf(7, 7, 9), intArrayOf(8, 8, 10), intArrayOf(9, 8, 13), intArrayOf(10, 9, 14), intArrayOf(11, 10, 15), intArrayOf(12, 11, 16), intArrayOf(13, 12, 17), intArrayOf(14, 13, 18), intArrayOf(15, 14, 19), intArrayOf(16, 15, 20), intArrayOf(17, 16, 22), intArrayOf(18, 17, 23), intArrayOf(18, 19, 24), intArrayOf(18, 19, 24), intArrayOf(19, 19, 27), intArrayOf(20, 20, 28), intArrayOf(21, 21, 29), intArrayOf(22, 22, 30), intArrayOf(23, 23, 31), intArrayOf(24, 24, 32), intArrayOf(25, 25, 33), intArrayOf(25, 25, 33), intArrayOf(26, 26, 34), intArrayOf(27, 27, 35), intArrayOf(28, 28, 38), intArrayOf(29, 29, 39), intArrayOf(30, 30, 40), intArrayOf(31, 31, 41), intArrayOf(32, 32, 42), intArrayOf(32, 32, 42), intArrayOf(33, 33, 45), intArrayOf(34, 34, 46), intArrayOf(35, 35, 47), intArrayOf(37, 37, 49), intArrayOf(38, 37, 51), intArrayOf(39, 38, 52), intArrayOf(40, 39, 55), intArrayOf(40, 39, 55), intArrayOf(41, 40, 56), intArrayOf(42, 41, 57), intArrayOf(43, 42, 58), intArrayOf(44, 43, 59), intArrayOf(45, 44, 60), intArrayOf(46, 45, 61), intArrayOf(47, 46, 62), intArrayOf(47, 46, 62), intArrayOf(48, 47, 65), intArrayOf(49, 48, 66), intArrayOf(48, 49, 67), intArrayOf(49, 50, 68), intArrayOf(50, 51, 71), intArrayOf(51, 52, 72), intArrayOf(52, 53, 73), intArrayOf(52, 53, 73), intArrayOf(53, 54, 74), intArrayOf(54, 55, 75), intArrayOf(55, 56, 76), intArrayOf(57, 58, 78), intArrayOf(58, 59, 80), intArrayOf(59, 60, 81), intArrayOf(60, 61, 82), intArrayOf(60, 61, 82), intArrayOf(61, 61, 85), intArrayOf(62, 62, 86), intArrayOf(63, 63, 87), intArrayOf(64, 64, 88), intArrayOf(65, 65, 89), intArrayOf(66, 66, 90), intArrayOf(67, 67, 91), intArrayOf(67, 67, 91), intArrayOf(68, 68, 92), intArrayOf(69, 69, 93), intArrayOf(70, 70, 96), intArrayOf(71, 71, 97), intArrayOf(72, 72, 98), intArrayOf(73, 73, 99), intArrayOf(73, 73, 99), intArrayOf(74, 74, 100), intArrayOf(75, 75, 103), intArrayOf(76, 76, 104), intArrayOf(77, 76, 107), intArrayOf(78, 77, 108), intArrayOf(79, 78, 109), intArrayOf(80, 79, 110), intArrayOf(80, 81, 111), intArrayOf(81, 82, 112), intArrayOf(82, 83, 114), intArrayOf(83, 84, 115), intArrayOf(84, 85, 116), intArrayOf(85, 86, 117), intArrayOf(86, 87, 118), intArrayOf(87, 88, 119), intArrayOf(88, 89, 120), intArrayOf(89, 90, 121), intArrayOf(90, 91, 121), intArrayOf(91, 92, 122), intArrayOf(90, 94, 123), intArrayOf(91, 95, 124), intArrayOf(92, 96, 123), intArrayOf(93, 97, 124), intArrayOf(94, 100, 126), intArrayOf(95, 101, 127), intArrayOf(96, 102, 128), intArrayOf(97, 103, 129), intArrayOf(98, 104, 130), intArrayOf(99, 105, 131), intArrayOf(100, 106, 132), intArrayOf(101, 107, 133), intArrayOf(102, 108, 132), intArrayOf(103, 109, 133), intArrayOf(103, 111, 134), intArrayOf(104, 112, 135), intArrayOf(105, 113, 136), intArrayOf(106, 114, 137), intArrayOf(106, 117, 139), intArrayOf(107, 118, 140), intArrayOf(108, 119, 141), intArrayOf(109, 120, 142), intArrayOf(110, 121, 143), intArrayOf(111, 122, 144), intArrayOf(112, 123, 143), intArrayOf(112, 123, 143), intArrayOf(114, 125, 145), intArrayOf(115, 126, 146), intArrayOf(116, 127, 147), intArrayOf(117, 128, 148), intArrayOf(117, 130, 149), intArrayOf(118, 131, 150), intArrayOf(119, 132, 149), intArrayOf(121, 134, 151), intArrayOf(120, 136, 152), intArrayOf(121, 137, 153), intArrayOf(122, 138, 154), intArrayOf(122, 138, 154), intArrayOf(124, 140, 156), intArrayOf(125, 141, 157), intArrayOf(125, 142, 158), intArrayOf(126, 143, 159), intArrayOf(128, 145, 161), intArrayOf(129, 146, 162), intArrayOf(129, 147, 161), intArrayOf(130, 148, 162), intArrayOf(131, 149, 163), intArrayOf(133, 151, 165), intArrayOf(134, 152, 166), intArrayOf(135, 153, 167), intArrayOf(135, 154, 168), intArrayOf(136, 155, 169), intArrayOf(137, 157, 168), intArrayOf(138, 158, 169), intArrayOf(139, 159, 170), intArrayOf(140, 160, 171), intArrayOf(139, 161, 172), intArrayOf(141, 163, 174), intArrayOf(142, 164, 175), intArrayOf(142, 164, 175), intArrayOf(143, 165, 176), intArrayOf(144, 166, 177), intArrayOf(145, 170, 177), intArrayOf(146, 171, 178), intArrayOf(147, 172, 179), intArrayOf(148, 173, 180), intArrayOf(149, 174, 179), intArrayOf(150, 175, 180), intArrayOf(151, 176, 181), intArrayOf(152, 177, 182), intArrayOf(153, 178, 183), intArrayOf(154, 179, 184), intArrayOf(153, 181, 185), intArrayOf(154, 182, 186), intArrayOf(155, 183, 187), intArrayOf(156, 184, 188), intArrayOf(158, 186, 190), intArrayOf(159, 187, 191), intArrayOf(159, 189, 191), intArrayOf(160, 190, 192), intArrayOf(161, 191, 193), intArrayOf(162, 192, 194), intArrayOf(162, 193, 195), intArrayOf(163, 194, 196), intArrayOf(164, 195, 197), intArrayOf(165, 196, 198), intArrayOf(166, 197, 199), intArrayOf(167, 198, 200), intArrayOf(169, 201, 200), intArrayOf(170, 202, 201), intArrayOf(171, 203, 202), intArrayOf(172, 204, 203), intArrayOf(173, 203, 203), intArrayOf(174, 204, 204), intArrayOf(176, 206, 206), intArrayOf(177, 207, 207), intArrayOf(179, 207, 208), intArrayOf(180, 208, 209), intArrayOf(183, 209, 210), intArrayOf(184, 210, 211), intArrayOf(185, 211, 210), intArrayOf(186, 212, 211), intArrayOf(188, 214, 213), intArrayOf(188, 214, 213), intArrayOf(190, 214, 216), intArrayOf(191, 215, 217), intArrayOf(194, 215, 216), intArrayOf(195, 216, 217), intArrayOf(196, 217, 218), intArrayOf(197, 218, 219), intArrayOf(199, 219, 218), intArrayOf(200, 220, 219), intArrayOf(201, 221, 220), intArrayOf(202, 222, 221), intArrayOf(204, 222, 222), intArrayOf(205, 223, 223), intArrayOf(206, 224, 224), intArrayOf(207, 225, 225), intArrayOf(210, 226, 226), intArrayOf(211, 227, 227), intArrayOf(210, 228, 228), intArrayOf(212, 230, 230), intArrayOf(215, 231, 231), intArrayOf(216, 232, 232), intArrayOf(219, 230, 232), intArrayOf(220, 231, 233), intArrayOf(221, 233, 233), intArrayOf(222, 234, 234), intArrayOf(223, 235, 235), intArrayOf(225, 237, 237), intArrayOf(227, 239, 239), intArrayOf(227, 239, 239), intArrayOf(228, 238, 239), intArrayOf(229, 239, 240), intArrayOf(232, 240, 242), intArrayOf(233, 241, 243), intArrayOf(234, 243, 242), intArrayOf(235, 244, 243), intArrayOf(238, 244, 244), intArrayOf(239, 245, 245), intArrayOf(240, 246, 246), intArrayOf(241, 247, 247), intArrayOf(243, 247, 248), intArrayOf(244, 248, 249), intArrayOf(245, 249, 250), intArrayOf(246, 250, 251), intArrayOf(249, 250, 252), intArrayOf(250, 251, 253), intArrayOf(251, 253, 252), intArrayOf(252, 254, 253), intArrayOf(254, 254, 254), intArrayOf(255, 255, 255) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/CoolLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.CoolLUT * @author: Tony Shen * @date: 2024/6/17 14:10 * @version: V1.0 <描述当前版本功能> */ object CoolLUT { var COOL_LUT = arrayOf( intArrayOf(0, 255, 255), intArrayOf(0, 254, 255), intArrayOf(1, 253, 255), intArrayOf(3, 252, 255), intArrayOf(4, 251, 255), intArrayOf(5, 250, 255), intArrayOf(7, 248, 255), intArrayOf(7, 248, 255), intArrayOf(8, 247, 254), intArrayOf(8, 247, 254), intArrayOf(11, 245, 255), intArrayOf(11, 245, 255), intArrayOf(12, 242, 255), intArrayOf(12, 242, 255), intArrayOf(15, 241, 255), intArrayOf(14, 240, 254), intArrayOf(16, 239, 255), intArrayOf(16, 239, 255), intArrayOf(18, 237, 255), intArrayOf(19, 236, 255), intArrayOf(20, 235, 255), intArrayOf(22, 234, 255), intArrayOf(24, 233, 255), intArrayOf(23, 232, 255), intArrayOf(25, 231, 255), intArrayOf(25, 231, 255), intArrayOf(27, 228, 255), intArrayOf(27, 228, 255), intArrayOf(29, 227, 255), intArrayOf(28, 226, 255), intArrayOf(30, 225, 255), intArrayOf(30, 225, 255), intArrayOf(33, 223, 255), intArrayOf(32, 222, 254), intArrayOf(34, 221, 255), intArrayOf(34, 221, 255), intArrayOf(37, 219, 255), intArrayOf(36, 218, 255), intArrayOf(39, 217, 255), intArrayOf(39, 217, 255), intArrayOf(39, 215, 255), intArrayOf(39, 215, 255), intArrayOf(42, 213, 255), intArrayOf(43, 212, 255), intArrayOf(44, 211, 255), intArrayOf(45, 210, 255), intArrayOf(48, 208, 255), intArrayOf(47, 207, 255), intArrayOf(50, 206, 255), intArrayOf(50, 206, 255), intArrayOf(50, 204, 254), intArrayOf(50, 204, 254), intArrayOf(52, 204, 254), intArrayOf(51, 203, 253), intArrayOf(54, 201, 255), intArrayOf(55, 200, 255), intArrayOf(56, 199, 255), intArrayOf(57, 198, 254), intArrayOf(60, 196, 255), intArrayOf(60, 196, 255), intArrayOf(61, 195, 255), intArrayOf(60, 194, 255), intArrayOf(61, 193, 255), intArrayOf(61, 193, 255), intArrayOf(63, 191, 254), intArrayOf(63, 191, 254), intArrayOf(66, 189, 255), intArrayOf(66, 188, 255), intArrayOf(68, 187, 255), intArrayOf(69, 186, 255), intArrayOf(72, 184, 255), intArrayOf(71, 183, 255), intArrayOf(72, 183, 255), intArrayOf(72, 183, 255), intArrayOf(74, 180, 254), intArrayOf(74, 180, 254), intArrayOf(77, 179, 254), intArrayOf(77, 179, 254), intArrayOf(77, 177, 253), intArrayOf(77, 177, 253), intArrayOf(80, 175, 255), intArrayOf(79, 174, 255), intArrayOf(82, 173, 255), intArrayOf(82, 173, 255), intArrayOf(85, 171, 255), intArrayOf(84, 170, 255), intArrayOf(87, 169, 255), intArrayOf(87, 169, 255), intArrayOf(87, 167, 254), intArrayOf(87, 167, 254), intArrayOf(90, 165, 255), intArrayOf(91, 164, 255), intArrayOf(92, 163, 255), intArrayOf(93, 162, 255), intArrayOf(96, 161, 255), intArrayOf(95, 160, 254), intArrayOf(98, 158, 255), intArrayOf(98, 158, 255), intArrayOf(99, 157, 255), intArrayOf(98, 156, 255), intArrayOf(100, 155, 255), intArrayOf(100, 155, 255), intArrayOf(102, 154, 255), intArrayOf(103, 152, 255), intArrayOf(104, 151, 255), intArrayOf(106, 150, 255), intArrayOf(107, 148, 255), intArrayOf(107, 148, 255), intArrayOf(109, 147, 255), intArrayOf(108, 146, 255), intArrayOf(109, 145, 255), intArrayOf(109, 145, 255), intArrayOf(111, 143, 254), intArrayOf(111, 143, 254), intArrayOf(114, 141, 255), intArrayOf(115, 140, 255), intArrayOf(116, 139, 255), intArrayOf(117, 138, 255), intArrayOf(120, 137, 255), intArrayOf(119, 136, 254), intArrayOf(120, 134, 255), intArrayOf(120, 134, 255), intArrayOf(123, 133, 255), intArrayOf(122, 132, 255), intArrayOf(125, 131, 255), intArrayOf(125, 131, 255), intArrayOf(126, 130, 255), intArrayOf(125, 129, 255), intArrayOf(128, 127, 255), intArrayOf(128, 127, 255), intArrayOf(130, 125, 254), intArrayOf(131, 124, 254), intArrayOf(133, 123, 254), intArrayOf(133, 122, 253), intArrayOf(134, 120, 255), intArrayOf(134, 120, 255), intArrayOf(137, 119, 255), intArrayOf(136, 118, 254), intArrayOf(138, 117, 255), intArrayOf(139, 116, 255), intArrayOf(140, 116, 255), intArrayOf(141, 114, 255), intArrayOf(144, 112, 255), intArrayOf(144, 112, 255), intArrayOf(144, 111, 254), intArrayOf(144, 111, 254), intArrayOf(147, 109, 255), intArrayOf(146, 108, 255), intArrayOf(149, 107, 255), intArrayOf(149, 107, 255), intArrayOf(151, 105, 255), intArrayOf(150, 104, 255), intArrayOf(152, 103, 255), intArrayOf(152, 103, 255), intArrayOf(154, 101, 254), intArrayOf(155, 100, 254), intArrayOf(156, 99, 254), intArrayOf(158, 98, 254), intArrayOf(160, 96, 253), intArrayOf(160, 96, 253), intArrayOf(160, 95, 255), intArrayOf(159, 94, 255), intArrayOf(161, 93, 255), intArrayOf(163, 92, 255), intArrayOf(164, 91, 255), intArrayOf(165, 90, 255), intArrayOf(167, 88, 255), intArrayOf(167, 88, 255), intArrayOf(169, 86, 254), intArrayOf(169, 86, 254), intArrayOf(171, 85, 255), intArrayOf(171, 85, 255), intArrayOf(172, 82, 255), intArrayOf(172, 82, 255), intArrayOf(175, 81, 255), intArrayOf(174, 80, 254), intArrayOf(176, 79, 255), intArrayOf(176, 79, 255), intArrayOf(177, 77, 255), intArrayOf(179, 76, 255), intArrayOf(180, 75, 255), intArrayOf(182, 74, 255), intArrayOf(183, 72, 255), intArrayOf(183, 72, 255), intArrayOf(185, 71, 255), intArrayOf(184, 70, 254), intArrayOf(187, 68, 255), intArrayOf(187, 68, 255), intArrayOf(189, 67, 255), intArrayOf(188, 66, 255), intArrayOf(191, 64, 255), intArrayOf(191, 64, 255), intArrayOf(193, 62, 254), intArrayOf(193, 62, 254), intArrayOf(195, 61, 255), intArrayOf(194, 60, 255), intArrayOf(196, 58, 255), intArrayOf(196, 58, 255), intArrayOf(199, 57, 255), intArrayOf(198, 56, 254), intArrayOf(200, 55, 255), intArrayOf(200, 55, 255), intArrayOf(202, 53, 255), intArrayOf(203, 52, 255), intArrayOf(204, 51, 255), intArrayOf(206, 50, 255), intArrayOf(207, 48, 255), intArrayOf(207, 48, 255), intArrayOf(210, 46, 255), intArrayOf(209, 45, 254), intArrayOf(211, 44, 254), intArrayOf(211, 44, 254), intArrayOf(212, 44, 254), intArrayOf(211, 43, 253), intArrayOf(214, 41, 255), intArrayOf(215, 40, 255), intArrayOf(217, 39, 255), intArrayOf(217, 38, 254), intArrayOf(220, 36, 255), intArrayOf(220, 36, 255), intArrayOf(220, 34, 255), intArrayOf(220, 34, 255), intArrayOf(222, 34, 255), intArrayOf(222, 34, 255), intArrayOf(223, 31, 254), intArrayOf(223, 31, 254), intArrayOf(226, 29, 255), intArrayOf(227, 28, 255), intArrayOf(228, 27, 255), intArrayOf(229, 26, 255), intArrayOf(232, 24, 255), intArrayOf(231, 23, 255), intArrayOf(233, 23, 255), intArrayOf(232, 22, 255), intArrayOf(234, 20, 255), intArrayOf(234, 20, 255), intArrayOf(237, 19, 255), intArrayOf(236, 18, 254), intArrayOf(239, 16, 254), intArrayOf(239, 16, 254), intArrayOf(242, 14, 255), intArrayOf(241, 13, 255), intArrayOf(242, 13, 255), intArrayOf(242, 13, 255), intArrayOf(244, 10, 255), intArrayOf(244, 10, 255), intArrayOf(247, 9, 255), intArrayOf(247, 9, 255), intArrayOf(247, 7, 254), intArrayOf(247, 7, 254), intArrayOf(250, 5, 255), intArrayOf(250, 4, 255), intArrayOf(252, 3, 255), intArrayOf(253, 2, 255), intArrayOf(255, 1, 255), intArrayOf(255, 0, 254) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/HotLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.HotLUT * @author: Tony Shen * @date: 2024/6/17 14:16 * @version: V1.0 <描述当前版本功能> */ object HotLUT { var HOT_LUT = arrayOf( intArrayOf(0, 0, 0), intArrayOf(1, 1, 0), intArrayOf(4, 0, 0), intArrayOf(6, 0, 0), intArrayOf(9, 1, 0), intArrayOf(12, 0, 0), intArrayOf(14, 0, 0), intArrayOf(16, 0, 0), intArrayOf(20, 0, 0), intArrayOf(22, 1, 0), intArrayOf(24, 0, 0), intArrayOf(28, 0, 0), intArrayOf(30, 0, 0), intArrayOf(32, 0, 1), intArrayOf(35, 0, 0), intArrayOf(36, 0, 0), intArrayOf(40, 0, 0), intArrayOf(41, 1, 1), intArrayOf(44, 0, 0), intArrayOf(46, 0, 0), intArrayOf(49, 1, 1), intArrayOf(52, 0, 2), intArrayOf(54, 0, 0), intArrayOf(56, 0, 0), intArrayOf(60, 0, 0), intArrayOf(62, 1, 0), intArrayOf(64, 0, 0), intArrayOf(68, 0, 0), intArrayOf(70, 0, 0), intArrayOf(72, 0, 1), intArrayOf(75, 0, 0), intArrayOf(76, 0, 0), intArrayOf(80, 0, 0), intArrayOf(81, 1, 0), intArrayOf(84, 0, 0), intArrayOf(86, 0, 0), intArrayOf(89, 1, 0), intArrayOf(92, 0, 1), intArrayOf(94, 0, 0), intArrayOf(96, 0, 1), intArrayOf(100, 0, 0), intArrayOf(103, 0, 1), intArrayOf(104, 0, 0), intArrayOf(108, 0, 0), intArrayOf(110, 0, 0), intArrayOf(113, 1, 0), intArrayOf(115, 0, 0), intArrayOf(116, 0, 0), intArrayOf(120, 0, 1), intArrayOf(120, 0, 0), intArrayOf(124, 0, 0), intArrayOf(126, 0, 1), intArrayOf(129, 1, 0), intArrayOf(132, 0, 0), intArrayOf(134, 0, 0), intArrayOf(136, 0, 0), intArrayOf(140, 0, 0), intArrayOf(142, 0, 0), intArrayOf(144, 0, 0), intArrayOf(148, 0, 0), intArrayOf(150, 0, 0), intArrayOf(152, 0, 0), intArrayOf(155, 0, 0), intArrayOf(156, 0, 1), intArrayOf(160, 0, 0), intArrayOf(160, 0, 0), intArrayOf(164, 0, 0), intArrayOf(166, 1, 0), intArrayOf(169, 1, 0), intArrayOf(172, 0, 0), intArrayOf(174, 1, 0), intArrayOf(176, 1, 0), intArrayOf(180, 0, 0), intArrayOf(182, 0, 0), intArrayOf(184, 0, 0), intArrayOf(188, 0, 1), intArrayOf(190, 0, 0), intArrayOf(192, 0, 0), intArrayOf(195, 0, 0), intArrayOf(196, 0, 1), intArrayOf(200, 0, 0), intArrayOf(200, 0, 0), intArrayOf(204, 0, 0), intArrayOf(206, 0, 0), intArrayOf(209, 1, 0), intArrayOf(212, 0, 0), intArrayOf(214, 0, 0), intArrayOf(216, 1, 0), intArrayOf(220, 0, 0), intArrayOf(222, 0, 0), intArrayOf(224, 0, 1), intArrayOf(228, 0, 1), intArrayOf(230, 0, 2), intArrayOf(232, 0, 0), intArrayOf(235, 0, 0), intArrayOf(236, 0, 0), intArrayOf(240, 0, 0), intArrayOf(242, 0, 0), intArrayOf(244, 0, 0), intArrayOf(246, 0, 1), intArrayOf(250, 0, 1), intArrayOf(250, 0, 1), intArrayOf(254, 2, 1), intArrayOf(255, 3, 0), intArrayOf(254, 5, 1), intArrayOf(255, 8, 0), intArrayOf(254, 10, 0), intArrayOf(254, 14, 0), intArrayOf(255, 15, 0), intArrayOf(255, 18, 0), intArrayOf(255, 20, 0), intArrayOf(255, 23, 0), intArrayOf(255, 25, 1), intArrayOf(254, 29, 0), intArrayOf(255, 30, 0), intArrayOf(253, 33, 0), intArrayOf(255, 35, 0), intArrayOf(255, 39, 0), intArrayOf(255, 40, 0), intArrayOf(254, 43, 0), intArrayOf(255, 45, 0), intArrayOf(254, 48, 0), intArrayOf(255, 49, 0), intArrayOf(254, 53, 0), intArrayOf(255, 55, 1), intArrayOf(254, 58, 0), intArrayOf(255, 59, 0), intArrayOf(255, 63, 0), intArrayOf(254, 65, 0), intArrayOf(255, 68, 0), intArrayOf(254, 70, 0), intArrayOf(255, 73, 0), intArrayOf(255, 74, 0), intArrayOf(255, 78, 0), intArrayOf(255, 79, 1), intArrayOf(255, 83, 0), intArrayOf(255, 84, 0), intArrayOf(255, 88, 0), intArrayOf(255, 89, 0), intArrayOf(255, 93, 0), intArrayOf(255, 95, 0), intArrayOf(255, 98, 1), intArrayOf(255, 100, 0), intArrayOf(255, 104, 1), intArrayOf(255, 105, 0), intArrayOf(255, 109, 0), intArrayOf(255, 111, 0), intArrayOf(255, 113, 1), intArrayOf(255, 115, 0), intArrayOf(254, 119, 1), intArrayOf(255, 120, 2), intArrayOf(255, 123, 0), intArrayOf(255, 125, 1), intArrayOf(255, 128, 0), intArrayOf(255, 130, 1), intArrayOf(255, 133, 0), intArrayOf(255, 135, 0), intArrayOf(255, 137, 1), intArrayOf(255, 140, 1), intArrayOf(255, 142, 1), intArrayOf(255, 144, 0), intArrayOf(255, 148, 0), intArrayOf(255, 149, 0), intArrayOf(255, 152, 0), intArrayOf(255, 154, 0), intArrayOf(255, 158, 1), intArrayOf(255, 159, 0), intArrayOf(255, 161, 1), intArrayOf(255, 164, 1), intArrayOf(255, 166, 0), intArrayOf(255, 169, 2), intArrayOf(254, 172, 0), intArrayOf(255, 175, 0), intArrayOf(253, 178, 0), intArrayOf(255, 180, 0), intArrayOf(253, 183, 0), intArrayOf(255, 185, 0), intArrayOf(255, 187, 0), intArrayOf(255, 190, 0), intArrayOf(255, 192, 0), intArrayOf(255, 194, 1), intArrayOf(255, 197, 1), intArrayOf(255, 199, 1), intArrayOf(255, 202, 0), intArrayOf(255, 204, 0), intArrayOf(255, 207, 0), intArrayOf(255, 210, 0), intArrayOf(255, 212, 0), intArrayOf(255, 214, 0), intArrayOf(254, 218, 0), intArrayOf(255, 220, 0), intArrayOf(255, 223, 0), intArrayOf(254, 225, 1), intArrayOf(254, 227, 0), intArrayOf(254, 230, 0), intArrayOf(254, 232, 0), intArrayOf(255, 234, 1), intArrayOf(254, 237, 0), intArrayOf(255, 239, 0), intArrayOf(254, 242, 0), intArrayOf(255, 245, 0), intArrayOf(253, 248, 0), intArrayOf(255, 250, 1), intArrayOf(255, 251, 2), intArrayOf(254, 253, 5), intArrayOf(255, 254, 6), intArrayOf(255, 255, 11), intArrayOf(255, 255, 14), intArrayOf(255, 255, 21), intArrayOf(255, 255, 25), intArrayOf(255, 255, 30), intArrayOf(255, 255, 33), intArrayOf(255, 254, 40), intArrayOf(255, 254, 45), intArrayOf(255, 255, 51), intArrayOf(254, 255, 55), intArrayOf(255, 255, 59), intArrayOf(254, 255, 63), intArrayOf(254, 255, 69), intArrayOf(254, 255, 73), intArrayOf(255, 255, 81), intArrayOf(255, 255, 85), intArrayOf(254, 255, 89), intArrayOf(255, 255, 93), intArrayOf(254, 255, 101), intArrayOf(255, 255, 105), intArrayOf(255, 255, 109), intArrayOf(255, 255, 113), intArrayOf(255, 255, 121), intArrayOf(255, 255, 125), intArrayOf(255, 255, 131), intArrayOf(255, 255, 135), intArrayOf(255, 255, 139), intArrayOf(255, 255, 143), intArrayOf(254, 255, 149), intArrayOf(255, 255, 154), intArrayOf(255, 255, 162), intArrayOf(255, 255, 165), intArrayOf(255, 255, 169), intArrayOf(255, 255, 173), intArrayOf(254, 254, 180), intArrayOf(255, 255, 185), intArrayOf(255, 255, 190), intArrayOf(255, 255, 193), intArrayOf(255, 255, 201), intArrayOf(255, 254, 205), intArrayOf(255, 255, 211), intArrayOf(254, 255, 215), intArrayOf(255, 255, 219), intArrayOf(255, 255, 224), intArrayOf(253, 255, 229), intArrayOf(254, 255, 234), intArrayOf(254, 255, 241), intArrayOf(255, 255, 245), intArrayOf(254, 255, 249), intArrayOf(255, 255, 251) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/HsvLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.HsvLUT * @author: Tony Shen * @date: 2024/6/17 14:17 * @version: V1.0 <描述当前版本功能> */ object HsvLUT { var HSV_LUT = arrayOf( intArrayOf(253, 1, 0), intArrayOf(255, 6, 0), intArrayOf(255, 11, 1), intArrayOf(254, 17, 1), intArrayOf(255, 24, 1), intArrayOf(255, 30, 0), intArrayOf(254, 36, 0), intArrayOf(255, 42, 0), intArrayOf(254, 48, 0), intArrayOf(255, 54, 0), intArrayOf(254, 60, 0), intArrayOf(254, 67, 0), intArrayOf(255, 73, 0), intArrayOf(254, 79, 0), intArrayOf(254, 84, 0), intArrayOf(255, 90, 0), intArrayOf(255, 97, 0), intArrayOf(255, 102, 0), intArrayOf(255, 108, 2), intArrayOf(255, 113, 1), intArrayOf(255, 120, 0), intArrayOf(255, 126, 0), intArrayOf(255, 133, 0), intArrayOf(255, 138, 0), intArrayOf(255, 144, 0), intArrayOf(255, 150, 0), intArrayOf(255, 156, 0), intArrayOf(254, 162, 0), intArrayOf(255, 169, 0), intArrayOf(254, 175, 0), intArrayOf(255, 180, 0), intArrayOf(255, 185, 0), intArrayOf(255, 192, 0), intArrayOf(255, 197, 0), intArrayOf(255, 203, 1), intArrayOf(255, 210, 2), intArrayOf(255, 216, 0), intArrayOf(255, 222, 0), intArrayOf(255, 229, 0), intArrayOf(255, 234, 0), intArrayOf(255, 240, 1), intArrayOf(253, 245, 0), intArrayOf(252, 248, 1), intArrayOf(246, 251, 0), intArrayOf(243, 252, 1), intArrayOf(239, 254, 1), intArrayOf(235, 255, 0), intArrayOf(229, 255, 0), intArrayOf(222, 255, 0), intArrayOf(215, 255, 0), intArrayOf(209, 255, 0), intArrayOf(204, 255, 0), intArrayOf(198, 255, 0), intArrayOf(190, 255, 0), intArrayOf(185, 255, 1), intArrayOf(180, 255, 0), intArrayOf(174, 255, 0), intArrayOf(168, 255, 0), intArrayOf(163, 254, 0), intArrayOf(155, 255, 0), intArrayOf(150, 255, 0), intArrayOf(144, 255, 0), intArrayOf(138, 255, 1), intArrayOf(132, 255, 0), intArrayOf(125, 255, 0), intArrayOf(120, 255, 0), intArrayOf(114, 255, 0), intArrayOf(108, 255, 0), intArrayOf(101, 255, 0), intArrayOf(95, 255, 0), intArrayOf(90, 255, 2), intArrayOf(84, 255, 0), intArrayOf(78, 255, 0), intArrayOf(71, 255, 0), intArrayOf(67, 254, 0), intArrayOf(60, 255, 0), intArrayOf(54, 255, 0), intArrayOf(48, 255, 0), intArrayOf(41, 255, 1), intArrayOf(36, 255, 0), intArrayOf(30, 255, 0), intArrayOf(24, 255, 0), intArrayOf(18, 255, 0), intArrayOf(11, 255, 0), intArrayOf(7, 254, 0), intArrayOf(1, 254, 3), intArrayOf(0, 254, 6), intArrayOf(0, 255, 11), intArrayOf(0, 255, 19), intArrayOf(0, 255, 24), intArrayOf(0, 255, 31), intArrayOf(1, 255, 37), intArrayOf(1, 255, 45), intArrayOf(1, 254, 49), intArrayOf(0, 255, 55), intArrayOf(0, 255, 61), intArrayOf(0, 255, 67), intArrayOf(0, 255, 73), intArrayOf(0, 255, 79), intArrayOf(0, 255, 83), intArrayOf(0, 255, 91), intArrayOf(1, 255, 97), intArrayOf(1, 255, 104), intArrayOf(0, 255, 109), intArrayOf(0, 255, 115), intArrayOf(0, 255, 120), intArrayOf(0, 255, 127), intArrayOf(0, 255, 133), intArrayOf(0, 254, 140), intArrayOf(1, 254, 145), intArrayOf(0, 255, 151), intArrayOf(0, 254, 156), intArrayOf(0, 255, 163), intArrayOf(1, 255, 169), intArrayOf(0, 255, 175), intArrayOf(0, 254, 181), intArrayOf(0, 255, 187), intArrayOf(1, 255, 193), intArrayOf(1, 255, 201), intArrayOf(1, 255, 205), intArrayOf(0, 255, 213), intArrayOf(1, 255, 218), intArrayOf(0, 255, 225), intArrayOf(1, 255, 229), intArrayOf(1, 254, 236), intArrayOf(2, 254, 241), intArrayOf(0, 253, 243), intArrayOf(0, 251, 246), intArrayOf(0, 247, 249), intArrayOf(1, 245, 253), intArrayOf(0, 240, 253), intArrayOf(0, 234, 253), intArrayOf(0, 228, 255), intArrayOf(0, 222, 255), intArrayOf(0, 216, 255), intArrayOf(0, 210, 255), intArrayOf(0, 205, 255), intArrayOf(0, 199, 255), intArrayOf(0, 193, 255), intArrayOf(0, 187, 255), intArrayOf(0, 179, 254), intArrayOf(1, 173, 255), intArrayOf(0, 168, 255), intArrayOf(0, 162, 255), intArrayOf(0, 156, 255), intArrayOf(0, 150, 255), intArrayOf(0, 145, 254), intArrayOf(0, 140, 255), intArrayOf(0, 131, 255), intArrayOf(0, 126, 255), intArrayOf(0, 120, 255), intArrayOf(0, 114, 255), intArrayOf(0, 107, 255), intArrayOf(0, 102, 255), intArrayOf(0, 96, 255), intArrayOf(0, 90, 255), intArrayOf(1, 83, 255), intArrayOf(1, 77, 255), intArrayOf(1, 71, 255), intArrayOf(1, 66, 255), intArrayOf(0, 59, 255), intArrayOf(0, 55, 255), intArrayOf(0, 48, 255), intArrayOf(0, 43, 254), intArrayOf(1, 35, 254), intArrayOf(1, 30, 254), intArrayOf(1, 23, 255), intArrayOf(1, 18, 255), intArrayOf(0, 12, 255), intArrayOf(0, 7, 255), intArrayOf(0, 1, 252), intArrayOf(6, 0, 254), intArrayOf(12, 1, 255), intArrayOf(17, 0, 254), intArrayOf(26, 0, 255), intArrayOf(30, 0, 254), intArrayOf(37, 0, 254), intArrayOf(42, 1, 255), intArrayOf(47, 0, 254), intArrayOf(52, 1, 254), intArrayOf(60, 0, 254), intArrayOf(66, 1, 253), intArrayOf(72, 1, 255), intArrayOf(77, 0, 254), intArrayOf(83, 0, 254), intArrayOf(90, 0, 254), intArrayOf(96, 0, 255), intArrayOf(102, 1, 255), intArrayOf(109, 0, 254), intArrayOf(113, 0, 254), intArrayOf(120, 0, 255), intArrayOf(126, 1, 255), intArrayOf(132, 1, 255), intArrayOf(137, 0, 254), intArrayOf(144, 0, 255), intArrayOf(148, 1, 255), intArrayOf(156, 1, 255), intArrayOf(161, 0, 252), intArrayOf(167, 0, 254), intArrayOf(174, 0, 255), intArrayOf(180, 0, 255), intArrayOf(186, 1, 255), intArrayOf(191, 0, 255), intArrayOf(197, 0, 255), intArrayOf(205, 0, 255), intArrayOf(210, 0, 255), intArrayOf(216, 1, 255), intArrayOf(220, 0, 254), intArrayOf(227, 0, 255), intArrayOf(232, 1, 253), intArrayOf(238, 1, 255), intArrayOf(242, 1, 253), intArrayOf(246, 0, 249), intArrayOf(250, 0, 247), intArrayOf(253, 0, 243), intArrayOf(255, 0, 240), intArrayOf(255, 0, 234), intArrayOf(255, 0, 228), intArrayOf(255, 1, 222), intArrayOf(254, 1, 214), intArrayOf(254, 0, 210), intArrayOf(253, 0, 204), intArrayOf(255, 0, 200), intArrayOf(255, 0, 194), intArrayOf(254, 0, 186), intArrayOf(254, 1, 180), intArrayOf(254, 0, 174), intArrayOf(253, 0, 168), intArrayOf(255, 0, 162), intArrayOf(255, 0, 154), intArrayOf(255, 0, 150), intArrayOf(255, 0, 144), intArrayOf(254, 0, 137), intArrayOf(254, 0, 132), intArrayOf(255, 0, 128), intArrayOf(254, 1, 120), intArrayOf(255, 0, 114), intArrayOf(255, 0, 108), intArrayOf(255, 0, 102), intArrayOf(255, 0, 96), intArrayOf(255, 0, 91), intArrayOf(255, 0, 84), intArrayOf(255, 0, 78), intArrayOf(254, 0, 72), intArrayOf(255, 0, 66), intArrayOf(254, 1, 58), intArrayOf(255, 0, 54), intArrayOf(255, 0, 48), intArrayOf(255, 0, 41), intArrayOf(255, 0, 36), intArrayOf(255, 0, 32), intArrayOf(255, 0, 24), intArrayOf(255, 0, 18), intArrayOf(255, 0, 12), intArrayOf(255, 0, 6), intArrayOf(254, 0, 3) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/JetLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.JetLUT * @author: Tony Shen * @date: 2024/6/17 14:18 * @version: V1.0 <描述当前版本功能> */ object JetLUT { var JET_LUT = arrayOf( intArrayOf(0, 1, 128), intArrayOf(0, 0, 130), intArrayOf(0, 0, 132), intArrayOf(1, 0, 136), intArrayOf(0, 0, 142), intArrayOf(0, 0, 145), intArrayOf(0, 0, 150), intArrayOf(1, 0, 154), intArrayOf(0, 0, 158), intArrayOf(1, 0, 163), intArrayOf(0, 0, 166), intArrayOf(0, 0, 170), intArrayOf(0, 0, 175), intArrayOf(0, 0, 179), intArrayOf(0, 0, 182), intArrayOf(0, 0, 186), intArrayOf(0, 1, 190), intArrayOf(0, 0, 194), intArrayOf(1, 1, 197), intArrayOf(1, 0, 200), intArrayOf(0, 1, 207), intArrayOf(0, 0, 210), intArrayOf(1, 0, 215), intArrayOf(1, 0, 218), intArrayOf(0, 0, 222), intArrayOf(1, 0, 226), intArrayOf(0, 0, 230), intArrayOf(0, 0, 234), intArrayOf(0, 0, 240), intArrayOf(0, 0, 244), intArrayOf(0, 0, 246), intArrayOf(0, 1, 249), intArrayOf(1, 2, 253), intArrayOf(0, 5, 254), intArrayOf(0, 8, 255), intArrayOf(1, 13, 255), intArrayOf(0, 17, 255), intArrayOf(0, 21, 254), intArrayOf(1, 25, 255), intArrayOf(0, 29, 255), intArrayOf(0, 33, 255), intArrayOf(0, 38, 255), intArrayOf(0, 41, 255), intArrayOf(0, 44, 254), intArrayOf(1, 49, 255), intArrayOf(0, 53, 253), intArrayOf(0, 56, 255), intArrayOf(1, 61, 255), intArrayOf(0, 65, 255), intArrayOf(0, 70, 254), intArrayOf(0, 73, 255), intArrayOf(0, 77, 255), intArrayOf(0, 82, 255), intArrayOf(0, 86, 254), intArrayOf(0, 89, 255), intArrayOf(0, 94, 254), intArrayOf(0, 97, 255), intArrayOf(0, 101, 255), intArrayOf(1, 105, 255), intArrayOf(0, 109, 254), intArrayOf(0, 113, 254), intArrayOf(0, 118, 254), intArrayOf(0, 120, 255), intArrayOf(0, 124, 253), intArrayOf(0, 128, 255), intArrayOf(0, 133, 254), intArrayOf(1, 135, 255), intArrayOf(0, 140, 255), intArrayOf(0, 144, 255), intArrayOf(0, 148, 254), intArrayOf(0, 151, 254), intArrayOf(0, 157, 254), intArrayOf(0, 160, 255), intArrayOf(0, 164, 254), intArrayOf(0, 168, 255), intArrayOf(0, 172, 254), intArrayOf(0, 175, 254), intArrayOf(0, 180, 254), intArrayOf(1, 183, 255), intArrayOf(0, 187, 254), intArrayOf(0, 192, 255), intArrayOf(0, 196, 254), intArrayOf(0, 199, 255), intArrayOf(0, 204, 255), intArrayOf(0, 208, 255), intArrayOf(0, 213, 255), intArrayOf(0, 216, 255), intArrayOf(0, 220, 254), intArrayOf(1, 226, 255), intArrayOf(0, 229, 255), intArrayOf(0, 232, 255), intArrayOf(0, 237, 255), intArrayOf(0, 240, 255), intArrayOf(0, 244, 254), intArrayOf(1, 248, 255), intArrayOf(2, 251, 255), intArrayOf(4, 254, 252), intArrayOf(6, 255, 249), intArrayOf(10, 255, 245), intArrayOf(13, 255, 241), intArrayOf(18, 255, 237), intArrayOf(22, 255, 233), intArrayOf(27, 255, 230), intArrayOf(30, 255, 225), intArrayOf(34, 255, 222), intArrayOf(37, 255, 218), intArrayOf(43, 255, 215), intArrayOf(45, 255, 208), intArrayOf(52, 254, 206), intArrayOf(56, 254, 201), intArrayOf(60, 255, 197), intArrayOf(62, 255, 192), intArrayOf(66, 255, 189), intArrayOf(70, 255, 185), intArrayOf(73, 255, 181), intArrayOf(77, 255, 177), intArrayOf(82, 255, 175), intArrayOf(86, 255, 168), intArrayOf(90, 254, 165), intArrayOf(94, 255, 161), intArrayOf(97, 255, 158), intArrayOf(101, 255, 154), intArrayOf(105, 254, 150), intArrayOf(109, 255, 144), intArrayOf(116, 254, 142), intArrayOf(120, 255, 137), intArrayOf(122, 254, 132), intArrayOf(126, 255, 128), intArrayOf(129, 255, 125), intArrayOf(131, 255, 120), intArrayOf(135, 255, 117), intArrayOf(139, 255, 113), intArrayOf(146, 255, 110), intArrayOf(149, 255, 105), intArrayOf(154, 255, 101), intArrayOf(158, 255, 98), intArrayOf(161, 255, 94), intArrayOf(164, 255, 90), intArrayOf(169, 255, 86), intArrayOf(173, 255, 82), intArrayOf(178, 255, 79), intArrayOf(181, 255, 74), intArrayOf(185, 255, 69), intArrayOf(189, 255, 65), intArrayOf(193, 254, 62), intArrayOf(197, 255, 57), intArrayOf(201, 255, 55), intArrayOf(204, 255, 50), intArrayOf(209, 254, 47), intArrayOf(212, 255, 41), intArrayOf(218, 255, 38), intArrayOf(221, 255, 34), intArrayOf(224, 255, 30), intArrayOf(228, 255, 26), intArrayOf(233, 255, 24), intArrayOf(237, 255, 17), intArrayOf(243, 254, 14), intArrayOf(246, 254, 10), intArrayOf(250, 254, 7), intArrayOf(254, 255, 4), intArrayOf(255, 251, 2), intArrayOf(255, 248, 0), intArrayOf(254, 244, 0), intArrayOf(255, 240, 0), intArrayOf(254, 237, 0), intArrayOf(254, 232, 0), intArrayOf(255, 228, 0), intArrayOf(255, 225, 0), intArrayOf(254, 220, 0), intArrayOf(255, 216, 0), intArrayOf(255, 212, 0), intArrayOf(255, 207, 0), intArrayOf(255, 204, 1), intArrayOf(255, 200, 1), intArrayOf(254, 196, 1), intArrayOf(255, 192, 0), intArrayOf(255, 188, 1), intArrayOf(254, 184, 0), intArrayOf(255, 180, 0), intArrayOf(254, 177, 0), intArrayOf(254, 172, 0), intArrayOf(255, 168, 1), intArrayOf(255, 164, 1), intArrayOf(255, 159, 0), intArrayOf(255, 156, 1), intArrayOf(255, 151, 0), intArrayOf(255, 148, 0), intArrayOf(255, 143, 2), intArrayOf(255, 139, 0), intArrayOf(255, 136, 0), intArrayOf(255, 132, 2), intArrayOf(255, 128, 0), intArrayOf(255, 125, 1), intArrayOf(255, 121, 0), intArrayOf(255, 117, 0), intArrayOf(255, 113, 0), intArrayOf(255, 108, 0), intArrayOf(255, 104, 0), intArrayOf(255, 101, 0), intArrayOf(255, 97, 0), intArrayOf(255, 93, 0), intArrayOf(254, 89, 0), intArrayOf(254, 85, 0), intArrayOf(254, 81, 2), intArrayOf(255, 76, 1), intArrayOf(255, 72, 0), intArrayOf(255, 69, 2), intArrayOf(255, 64, 0), intArrayOf(255, 61, 0), intArrayOf(255, 57, 0), intArrayOf(254, 53, 0), intArrayOf(255, 49, 0), intArrayOf(254, 46, 0), intArrayOf(254, 41, 1), intArrayOf(255, 37, 1), intArrayOf(255, 33, 0), intArrayOf(253, 28, 0), intArrayOf(255, 25, 1), intArrayOf(255, 21, 0), intArrayOf(255, 16, 1), intArrayOf(255, 13, 1), intArrayOf(255, 9, 0), intArrayOf(254, 5, 1), intArrayOf(252, 3, 0), intArrayOf(250, 0, 1), intArrayOf(246, 1, 0), intArrayOf(244, 0, 0), intArrayOf(239, 0, 0), intArrayOf(234, 0, 1), intArrayOf(230, 0, 0), intArrayOf(226, 0, 1), intArrayOf(223, 1, 0), intArrayOf(218, 0, 0), intArrayOf(214, 0, 0), intArrayOf(210, 0, 0), intArrayOf(206, 0, 0), intArrayOf(201, 1, 1), intArrayOf(197, 1, 2), intArrayOf(194, 0, 1), intArrayOf(190, 0, 0), intArrayOf(186, 1, 0), intArrayOf(183, 1, 0), intArrayOf(180, 0, 0), intArrayOf(175, 0, 0), intArrayOf(170, 0, 0), intArrayOf(166, 1, 0), intArrayOf(163, 0, 1), intArrayOf(159, 1, 0), intArrayOf(154, 0, 0), intArrayOf(150, 0, 0), intArrayOf(146, 0, 1), intArrayOf(143, 1, 0), intArrayOf(139, 1, 1), intArrayOf(134, 0, 0), intArrayOf(132, 0, 0), intArrayOf(129, 0, 0) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/LUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.LUT * @author: Tony Shen * @date: 2024/6/16 15:43 * @version: V1.0 <描述当前版本功能> */ const val AUTUMN_STYLE: Int = 0 const val BONE_STYLE = 1 const val COOL_STYLE = 2 const val HOT_STYLE = 3 const val HSV_STYLE = 4 const val JET_STYLE = 5 const val OCEAN_STYLE = 6 const val PINK_STYLE = 7 const val RAINBOW_STYLE = 8 const val SPRING_STYLE = 9 const val SUMMER_STYLE = 10 const val WINTER_STYLE = 11 fun getColorFilterLUT(style: Int): Array { return when (style) { AUTUMN_STYLE -> AutumnLUT.AUTUMN_LUT BONE_STYLE -> BoneLUT.BONE_LUT COOL_STYLE -> CoolLUT.COOL_LUT HOT_STYLE -> HotLUT.HOT_LUT HSV_STYLE -> HsvLUT.HSV_LUT JET_STYLE -> JetLUT.JET_LUT OCEAN_STYLE -> OceanLUT.OCEAN_LUT PINK_STYLE -> PinkLUT.PINK_LUT RAINBOW_STYLE -> RainbowLUT.RAINBOW_LUT SPRING_STYLE -> SpringLUT.SPRING_LUT SUMMER_STYLE -> SummerLUT.SUMMER_LUT WINTER_STYLE -> WinterLUT.WINTER_LUT else -> AutumnLUT.AUTUMN_LUT } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/OceanLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.OceanLUT * @author: Tony Shen * @date: 2024/6/17 14:33 * @version: V1.0 <描述当前版本功能> */ object OceanLUT { var OCEAN_LUT = arrayOf( intArrayOf(0, 0, 0), intArrayOf(0, 0, 0), intArrayOf(0, 0, 2), intArrayOf(0, 0, 2), intArrayOf(0, 0, 4), intArrayOf(1, 0, 5), intArrayOf(1, 0, 6), intArrayOf(1, 0, 6), intArrayOf(0, 0, 8), intArrayOf(0, 0, 8), intArrayOf(0, 0, 10), intArrayOf(0, 0, 10), intArrayOf(0, 0, 12), intArrayOf(0, 0, 12), intArrayOf(1, 0, 14), intArrayOf(1, 0, 14), intArrayOf(1, 0, 16), intArrayOf(1, 0, 16), intArrayOf(1, 0, 18), intArrayOf(1, 0, 18), intArrayOf(0, 0, 20), intArrayOf(0, 0, 20), intArrayOf(0, 1, 22), intArrayOf(0, 1, 22), intArrayOf(0, 0, 24), intArrayOf(0, 0, 24), intArrayOf(0, 0, 26), intArrayOf(0, 0, 26), intArrayOf(0, 0, 28), intArrayOf(0, 0, 28), intArrayOf(0, 0, 30), intArrayOf(1, 0, 31), intArrayOf(0, 1, 32), intArrayOf(0, 1, 32), intArrayOf(0, 0, 34), intArrayOf(0, 0, 34), intArrayOf(0, 0, 36), intArrayOf(0, 0, 36), intArrayOf(0, 0, 38), intArrayOf(0, 0, 38), intArrayOf(1, 0, 40), intArrayOf(1, 0, 40), intArrayOf(1, 0, 42), intArrayOf(1, 0, 42), intArrayOf(0, 0, 44), intArrayOf(0, 0, 44), intArrayOf(0, 0, 46), intArrayOf(0, 0, 46), intArrayOf(0, 0, 48), intArrayOf(0, 1, 49), intArrayOf(0, 0, 50), intArrayOf(0, 0, 50), intArrayOf(0, 0, 52), intArrayOf(0, 0, 52), intArrayOf(0, 0, 54), intArrayOf(0, 0, 54), intArrayOf(0, 0, 56), intArrayOf(0, 0, 56), intArrayOf(0, 1, 58), intArrayOf(0, 1, 58), intArrayOf(0, 0, 60), intArrayOf(0, 0, 60), intArrayOf(0, 0, 62), intArrayOf(0, 0, 62), intArrayOf(0, 0, 64), intArrayOf(0, 0, 64), intArrayOf(1, 0, 66), intArrayOf(1, 0, 66), intArrayOf(0, 1, 68), intArrayOf(0, 1, 68), intArrayOf(0, 0, 70), intArrayOf(0, 0, 70), intArrayOf(0, 0, 72), intArrayOf(0, 0, 72), intArrayOf(0, 0, 74), intArrayOf(1, 1, 75), intArrayOf(1, 0, 76), intArrayOf(1, 0, 76), intArrayOf(1, 0, 78), intArrayOf(1, 0, 78), intArrayOf(0, 0, 80), intArrayOf(0, 0, 80), intArrayOf(0, 0, 82), intArrayOf(0, 0, 82), intArrayOf(0, 0, 86), intArrayOf(0, 0, 86), intArrayOf(0, 2, 87), intArrayOf(1, 3, 88), intArrayOf(0, 5, 87), intArrayOf(0, 7, 88), intArrayOf(1, 8, 89), intArrayOf(0, 10, 90), intArrayOf(0, 11, 91), intArrayOf(0, 13, 92), intArrayOf(0, 14, 95), intArrayOf(0, 15, 96), intArrayOf(1, 16, 97), intArrayOf(1, 18, 98), intArrayOf(2, 19, 99), intArrayOf(0, 21, 100), intArrayOf(1, 22, 101), intArrayOf(0, 25, 102), intArrayOf(0, 26, 103), intArrayOf(0, 27, 104), intArrayOf(1, 28, 105), intArrayOf(1, 30, 106), intArrayOf(2, 31, 107), intArrayOf(0, 34, 108), intArrayOf(1, 35, 109), intArrayOf(0, 37, 110), intArrayOf(0, 38, 111), intArrayOf(0, 40, 112), intArrayOf(0, 41, 113), intArrayOf(0, 42, 114), intArrayOf(0, 44, 115), intArrayOf(0, 46, 116), intArrayOf(0, 47, 117), intArrayOf(0, 49, 118), intArrayOf(0, 50, 119), intArrayOf(1, 51, 120), intArrayOf(0, 53, 121), intArrayOf(0, 53, 121), intArrayOf(1, 56, 123), intArrayOf(0, 56, 123), intArrayOf(1, 58, 125), intArrayOf(0, 59, 125), intArrayOf(1, 62, 127), intArrayOf(1, 62, 127), intArrayOf(1, 65, 127), intArrayOf(0, 66, 127), intArrayOf(2, 68, 129), intArrayOf(0, 69, 129), intArrayOf(1, 71, 133), intArrayOf(0, 72, 133), intArrayOf(0, 74, 135), intArrayOf(0, 75, 135), intArrayOf(1, 77, 137), intArrayOf(0, 78, 137), intArrayOf(1, 80, 139), intArrayOf(0, 81, 139), intArrayOf(1, 83, 141), intArrayOf(0, 84, 141), intArrayOf(0, 86, 143), intArrayOf(0, 87, 143), intArrayOf(2, 88, 145), intArrayOf(0, 89, 145), intArrayOf(2, 91, 147), intArrayOf(0, 93, 147), intArrayOf(1, 95, 149), intArrayOf(0, 96, 149), intArrayOf(1, 98, 151), intArrayOf(0, 99, 151), intArrayOf(1, 101, 153), intArrayOf(0, 101, 153), intArrayOf(2, 103, 155), intArrayOf(0, 105, 155), intArrayOf(1, 107, 157), intArrayOf(0, 108, 157), intArrayOf(0, 110, 159), intArrayOf(0, 111, 159), intArrayOf(0, 114, 161), intArrayOf(0, 114, 161), intArrayOf(0, 116, 163), intArrayOf(0, 117, 163), intArrayOf(1, 119, 165), intArrayOf(0, 120, 165), intArrayOf(0, 123, 167), intArrayOf(0, 123, 167), intArrayOf(0, 125, 169), intArrayOf(0, 125, 169), intArrayOf(4, 127, 169), intArrayOf(5, 128, 170), intArrayOf(8, 130, 171), intArrayOf(10, 132, 173), intArrayOf(11, 133, 174), intArrayOf(14, 136, 175), intArrayOf(18, 136, 176), intArrayOf(20, 138, 176), intArrayOf(25, 138, 178), intArrayOf(27, 140, 180), intArrayOf(32, 141, 180), intArrayOf(34, 143, 182), intArrayOf(37, 145, 183), intArrayOf(39, 147, 183), intArrayOf(41, 148, 184), intArrayOf(44, 151, 185), intArrayOf(47, 152, 184), intArrayOf(48, 153, 185), intArrayOf(54, 154, 186), intArrayOf(56, 156, 188), intArrayOf(60, 157, 190), intArrayOf(62, 159, 191), intArrayOf(66, 161, 193), intArrayOf(67, 162, 192), intArrayOf(73, 164, 195), intArrayOf(75, 166, 197), intArrayOf(79, 166, 196), intArrayOf(81, 168, 198), intArrayOf(85, 168, 198), intArrayOf(87, 171, 199), intArrayOf(91, 172, 201), intArrayOf(92, 173, 200), intArrayOf(96, 176, 201), intArrayOf(98, 178, 203), intArrayOf(102, 178, 202), intArrayOf(104, 180, 204), intArrayOf(109, 181, 206), intArrayOf(111, 183, 207), intArrayOf(114, 184, 209), intArrayOf(116, 187, 209), intArrayOf(120, 188, 211), intArrayOf(122, 190, 213), intArrayOf(125, 190, 212), intArrayOf(128, 193, 215), intArrayOf(133, 194, 215), intArrayOf(134, 195, 214), intArrayOf(138, 196, 216), intArrayOf(140, 199, 217), intArrayOf(144, 200, 217), intArrayOf(146, 202, 219), intArrayOf(151, 202, 219), intArrayOf(153, 204, 221), intArrayOf(157, 204, 222), intArrayOf(159, 206, 222), intArrayOf(162, 208, 224), intArrayOf(164, 210, 225), intArrayOf(169, 211, 227), intArrayOf(171, 213, 229), intArrayOf(177, 214, 230), intArrayOf(178, 215, 231), intArrayOf(180, 216, 230), intArrayOf(183, 219, 231), intArrayOf(186, 220, 232), intArrayOf(188, 222, 232), intArrayOf(191, 224, 233), intArrayOf(193, 226, 235), intArrayOf(198, 227, 235), intArrayOf(200, 229, 237), intArrayOf(205, 229, 239), intArrayOf(207, 232, 239), intArrayOf(209, 232, 240), intArrayOf(212, 235, 241), intArrayOf(217, 236, 243), intArrayOf(217, 236, 243), intArrayOf(223, 238, 245), intArrayOf(225, 240, 247), intArrayOf(230, 241, 247), intArrayOf(232, 243, 247), intArrayOf(234, 243, 248), intArrayOf(237, 247, 249), intArrayOf(240, 248, 250), intArrayOf(240, 248, 250), intArrayOf(246, 250, 251), intArrayOf(248, 252, 253), intArrayOf(253, 253, 255), intArrayOf(255, 255, 255) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/PinkLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.PinkLUT * @author: Tony Shen * @date: 2024/6/17 14:35 * @version: V1.0 <描述当前版本功能> */ object PinkLUT { var PINK_LUT = arrayOf( intArrayOf(1, 0, 0), intArrayOf(10, 6, 5), intArrayOf(19, 14, 11), intArrayOf(29, 19, 18), intArrayOf(38, 26, 26), intArrayOf(44, 30, 30), intArrayOf(49, 31, 31), intArrayOf(52, 34, 34), intArrayOf(57, 37, 36), intArrayOf(59, 39, 38), intArrayOf(63, 42, 41), intArrayOf(65, 44, 43), intArrayOf(69, 45, 45), intArrayOf(71, 47, 47), intArrayOf(74, 48, 49), intArrayOf(76, 50, 51), intArrayOf(80, 52, 51), intArrayOf(82, 54, 53), intArrayOf(85, 55, 55), intArrayOf(86, 56, 56), intArrayOf(88, 58, 58), intArrayOf(90, 60, 60), intArrayOf(93, 61, 62), intArrayOf(94, 62, 63), intArrayOf(98, 64, 63), intArrayOf(99, 65, 64), intArrayOf(101, 65, 65), intArrayOf(103, 67, 67), intArrayOf(105, 69, 69), intArrayOf(106, 70, 70), intArrayOf(109, 70, 71), intArrayOf(111, 72, 73), intArrayOf(113, 75, 74), intArrayOf(114, 76, 75), intArrayOf(115, 77, 76), intArrayOf(116, 78, 77), intArrayOf(118, 78, 78), intArrayOf(120, 80, 80), intArrayOf(122, 80, 81), intArrayOf(123, 81, 82), intArrayOf(126, 82, 81), intArrayOf(127, 83, 82), intArrayOf(129, 83, 83), intArrayOf(131, 85, 85), intArrayOf(134, 86, 86), intArrayOf(135, 87, 87), intArrayOf(136, 88, 88), intArrayOf(137, 89, 89), intArrayOf(138, 90, 90), intArrayOf(139, 91, 91), intArrayOf(141, 93, 93), intArrayOf(142, 94, 94), intArrayOf(143, 95, 95), intArrayOf(144, 96, 96), intArrayOf(146, 96, 97), intArrayOf(147, 97, 98), intArrayOf(149, 98, 97), intArrayOf(150, 99, 98), intArrayOf(151, 100, 99), intArrayOf(152, 101, 100), intArrayOf(153, 102, 101), intArrayOf(154, 103, 102), intArrayOf(157, 103, 103), intArrayOf(157, 103, 103), intArrayOf(158, 104, 104), intArrayOf(160, 106, 106), intArrayOf(162, 106, 107), intArrayOf(163, 107, 108), intArrayOf(164, 108, 107), intArrayOf(164, 108, 107), intArrayOf(165, 109, 108), intArrayOf(166, 110, 109), intArrayOf(169, 111, 110), intArrayOf(170, 112, 111), intArrayOf(172, 112, 112), intArrayOf(173, 113, 113), intArrayOf(174, 114, 114), intArrayOf(174, 114, 114), intArrayOf(175, 115, 115), intArrayOf(176, 116, 116), intArrayOf(177, 117, 117), intArrayOf(178, 118, 118), intArrayOf(180, 118, 119), intArrayOf(181, 119, 120), intArrayOf(181, 120, 119), intArrayOf(182, 121, 120), intArrayOf(185, 121, 121), intArrayOf(186, 122, 122), intArrayOf(187, 121, 122), intArrayOf(188, 122, 123), intArrayOf(189, 123, 124), intArrayOf(190, 124, 125), intArrayOf(191, 126, 124), intArrayOf(192, 127, 125), intArrayOf(193, 128, 126), intArrayOf(194, 129, 127), intArrayOf(195, 130, 128), intArrayOf(195, 130, 128), intArrayOf(196, 131, 129), intArrayOf(197, 132, 130), intArrayOf(197, 133, 131), intArrayOf(199, 135, 133), intArrayOf(198, 137, 132), intArrayOf(198, 137, 132), intArrayOf(199, 140, 132), intArrayOf(200, 141, 133), intArrayOf(200, 143, 134), intArrayOf(201, 144, 135), intArrayOf(199, 145, 135), intArrayOf(201, 147, 137), intArrayOf(201, 149, 136), intArrayOf(201, 149, 136), intArrayOf(201, 152, 138), intArrayOf(201, 152, 138), intArrayOf(202, 153, 139), intArrayOf(204, 155, 141), intArrayOf(203, 156, 140), intArrayOf(204, 157, 141), intArrayOf(205, 159, 143), intArrayOf(205, 159, 143), intArrayOf(204, 161, 142), intArrayOf(205, 162, 143), intArrayOf(206, 163, 144), intArrayOf(207, 164, 145), intArrayOf(207, 166, 144), intArrayOf(208, 167, 145), intArrayOf(208, 167, 145), intArrayOf(207, 169, 146), intArrayOf(208, 170, 147), intArrayOf(208, 172, 148), intArrayOf(209, 173, 149), intArrayOf(210, 174, 150), intArrayOf(210, 176, 149), intArrayOf(210, 176, 149), intArrayOf(211, 177, 150), intArrayOf(212, 178, 151), intArrayOf(211, 180, 152), intArrayOf(212, 181, 153), intArrayOf(213, 182, 153), intArrayOf(214, 183, 154), intArrayOf(213, 184, 154), intArrayOf(214, 185, 155), intArrayOf(215, 186, 156), intArrayOf(216, 187, 157), intArrayOf(214, 188, 155), intArrayOf(215, 189, 156), intArrayOf(216, 190, 157), intArrayOf(217, 191, 158), intArrayOf(216, 192, 158), intArrayOf(217, 193, 159), intArrayOf(217, 194, 160), intArrayOf(218, 195, 161), intArrayOf(217, 197, 160), intArrayOf(218, 198, 161), intArrayOf(219, 199, 162), intArrayOf(219, 199, 162), intArrayOf(219, 201, 163), intArrayOf(219, 201, 163), intArrayOf(220, 202, 164), intArrayOf(221, 203, 165), intArrayOf(220, 205, 164), intArrayOf(220, 205, 164), intArrayOf(221, 206, 165), intArrayOf(222, 207, 166), intArrayOf(222, 209, 167), intArrayOf(222, 209, 167), intArrayOf(223, 210, 166), intArrayOf(224, 211, 167), intArrayOf(224, 213, 168), intArrayOf(225, 214, 169), intArrayOf(225, 214, 169), intArrayOf(226, 215, 170), intArrayOf(225, 217, 171), intArrayOf(225, 217, 171), intArrayOf(226, 218, 172), intArrayOf(226, 218, 172), intArrayOf(227, 219, 172), intArrayOf(228, 220, 173), intArrayOf(228, 222, 174), intArrayOf(228, 222, 174), intArrayOf(227, 223, 175), intArrayOf(228, 224, 176), intArrayOf(229, 225, 177), intArrayOf(229, 225, 177), intArrayOf(230, 227, 176), intArrayOf(230, 227, 176), intArrayOf(231, 228, 177), intArrayOf(232, 229, 178), intArrayOf(233, 230, 179), intArrayOf(233, 230, 179), intArrayOf(233, 231, 180), intArrayOf(233, 231, 180), intArrayOf(233, 233, 181), intArrayOf(233, 233, 181), intArrayOf(234, 234, 184), intArrayOf(235, 235, 185), intArrayOf(236, 235, 187), intArrayOf(236, 235, 187), intArrayOf(236, 235, 189), intArrayOf(237, 236, 190), intArrayOf(235, 236, 192), intArrayOf(235, 236, 192), intArrayOf(236, 237, 195), intArrayOf(236, 237, 195), intArrayOf(237, 238, 198), intArrayOf(238, 239, 199), intArrayOf(238, 238, 200), intArrayOf(238, 238, 200), intArrayOf(239, 239, 203), intArrayOf(239, 239, 203), intArrayOf(240, 240, 206), intArrayOf(240, 240, 206), intArrayOf(240, 239, 208), intArrayOf(241, 240, 209), intArrayOf(241, 240, 210), intArrayOf(242, 241, 211), intArrayOf(242, 243, 212), intArrayOf(242, 243, 212), intArrayOf(242, 242, 214), intArrayOf(243, 243, 215), intArrayOf(243, 243, 217), intArrayOf(243, 243, 217), intArrayOf(244, 244, 220), intArrayOf(244, 244, 220), intArrayOf(244, 243, 222), intArrayOf(245, 244, 223), intArrayOf(246, 245, 224), intArrayOf(246, 245, 224), intArrayOf(245, 247, 226), intArrayOf(245, 247, 226), intArrayOf(246, 247, 229), intArrayOf(246, 247, 229), intArrayOf(246, 247, 231), intArrayOf(248, 249, 233), intArrayOf(247, 248, 234), intArrayOf(247, 248, 234), intArrayOf(249, 249, 237), intArrayOf(249, 249, 237), intArrayOf(249, 249, 237), intArrayOf(250, 250, 238), intArrayOf(252, 249, 240), intArrayOf(253, 250, 241), intArrayOf(251, 251, 243), intArrayOf(251, 251, 243), intArrayOf(251, 251, 243), intArrayOf(252, 252, 244), intArrayOf(251, 252, 246), intArrayOf(251, 252, 246), intArrayOf(252, 253, 247), intArrayOf(253, 254, 248), intArrayOf(253, 254, 249), intArrayOf(254, 255, 250), intArrayOf(254, 254, 252), intArrayOf(254, 254, 252), intArrayOf(255, 255, 255), intArrayOf(255, 255, 255) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/RainbowLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.RainbowLUT * @author: Tony Shen * @date: 2024/6/17 14:38 * @version: V1.0 <描述当前版本功能> */ object RainbowLUT { var RAINBOW_LUT = arrayOf( intArrayOf(253, 1, 0), intArrayOf(255, 3, 0), intArrayOf(254, 5, 0), intArrayOf(254, 7, 0), intArrayOf(254, 10, 0), intArrayOf(254, 14, 0), intArrayOf(255, 15, 0), intArrayOf(254, 17, 0), intArrayOf(255, 20, 0), intArrayOf(254, 22, 0), intArrayOf(255, 24, 1), intArrayOf(255, 28, 0), intArrayOf(255, 30, 0), intArrayOf(255, 32, 0), intArrayOf(255, 35, 0), intArrayOf(255, 37, 0), intArrayOf(255, 40, 1), intArrayOf(254, 43, 0), intArrayOf(255, 45, 0), intArrayOf(254, 48, 0), intArrayOf(255, 50, 0), intArrayOf(254, 53, 0), intArrayOf(255, 55, 0), intArrayOf(254, 58, 0), intArrayOf(255, 59, 0), intArrayOf(255, 63, 0), intArrayOf(255, 64, 1), intArrayOf(255, 68, 0), intArrayOf(254, 70, 0), intArrayOf(254, 74, 0), intArrayOf(255, 75, 0), intArrayOf(254, 79, 0), intArrayOf(255, 80, 0), intArrayOf(254, 84, 0), intArrayOf(255, 85, 0), intArrayOf(254, 89, 0), intArrayOf(255, 90, 0), intArrayOf(254, 94, 0), intArrayOf(255, 95, 1), intArrayOf(255, 99, 0), intArrayOf(255, 100, 0), intArrayOf(255, 104, 0), intArrayOf(255, 105, 0), intArrayOf(255, 109, 0), intArrayOf(255, 110, 0), intArrayOf(255, 113, 1), intArrayOf(255, 115, 0), intArrayOf(255, 119, 1), intArrayOf(255, 121, 0), intArrayOf(255, 123, 0), intArrayOf(255, 126, 0), intArrayOf(255, 128, 1), intArrayOf(255, 130, 1), intArrayOf(254, 133, 0), intArrayOf(255, 134, 1), intArrayOf(255, 138, 0), intArrayOf(255, 139, 0), intArrayOf(254, 142, 0), intArrayOf(255, 144, 0), intArrayOf(255, 148, 0), intArrayOf(255, 150, 0), intArrayOf(255, 151, 0), intArrayOf(255, 154, 0), intArrayOf(255, 156, 0), intArrayOf(255, 159, 0), intArrayOf(254, 162, 0), intArrayOf(255, 165, 0), intArrayOf(254, 167, 0), intArrayOf(255, 170, 1), intArrayOf(253, 173, 0), intArrayOf(255, 175, 0), intArrayOf(254, 177, 1), intArrayOf(255, 180, 1), intArrayOf(255, 182, 0), intArrayOf(255, 184, 2), intArrayOf(255, 187, 0), intArrayOf(255, 190, 0), intArrayOf(255, 193, 0), intArrayOf(255, 195, 0), intArrayOf(255, 197, 0), intArrayOf(255, 200, 0), intArrayOf(254, 203, 0), intArrayOf(255, 205, 0), intArrayOf(255, 209, 1), intArrayOf(255, 210, 2), intArrayOf(254, 213, 1), intArrayOf(255, 214, 0), intArrayOf(254, 218, 0), intArrayOf(255, 219, 0), intArrayOf(255, 223, 0), intArrayOf(255, 224, 0), intArrayOf(254, 227, 0), intArrayOf(255, 229, 0), intArrayOf(255, 233, 0), intArrayOf(255, 234, 0), intArrayOf(254, 237, 0), intArrayOf(255, 239, 1), intArrayOf(254, 242, 0), intArrayOf(255, 244, 0), intArrayOf(255, 247, 0), intArrayOf(255, 250, 0), intArrayOf(253, 252, 1), intArrayOf(252, 253, 0), intArrayOf(248, 254, 0), intArrayOf(244, 254, 0), intArrayOf(239, 255, 0), intArrayOf(235, 254, 2), intArrayOf(229, 255, 0), intArrayOf(224, 255, 3), intArrayOf(220, 255, 2), intArrayOf(215, 255, 0), intArrayOf(209, 255, 0), intArrayOf(205, 255, 0), intArrayOf(200, 255, 0), intArrayOf(195, 255, 1), intArrayOf(189, 255, 0), intArrayOf(185, 255, 1), intArrayOf(180, 255, 0), intArrayOf(174, 255, 2), intArrayOf(170, 255, 1), intArrayOf(165, 255, 1), intArrayOf(162, 255, 0), intArrayOf(155, 255, 1), intArrayOf(150, 255, 0), intArrayOf(145, 254, 2), intArrayOf(140, 255, 1), intArrayOf(135, 254, 2), intArrayOf(130, 255, 1), intArrayOf(125, 255, 0), intArrayOf(120, 255, 0), intArrayOf(115, 255, 0), intArrayOf(110, 255, 0), intArrayOf(105, 255, 0), intArrayOf(100, 255, 0), intArrayOf(94, 255, 1), intArrayOf(90, 255, 0), intArrayOf(85, 255, 0), intArrayOf(81, 255, 0), intArrayOf(75, 255, 0), intArrayOf(70, 255, 0), intArrayOf(65, 255, 1), intArrayOf(60, 255, 0), intArrayOf(55, 254, 1), intArrayOf(50, 255, 0), intArrayOf(44, 255, 0), intArrayOf(39, 255, 0), intArrayOf(35, 255, 1), intArrayOf(31, 255, 0), intArrayOf(25, 254, 1), intArrayOf(20, 255, 0), intArrayOf(15, 255, 0), intArrayOf(10, 255, 1), intArrayOf(7, 254, 0), intArrayOf(3, 251, 4), intArrayOf(1, 248, 7), intArrayOf(1, 244, 12), intArrayOf(0, 240, 17), intArrayOf(0, 235, 20), intArrayOf(0, 230, 24), intArrayOf(0, 225, 27), intArrayOf(0, 220, 36), intArrayOf(1, 215, 41), intArrayOf(0, 210, 46), intArrayOf(0, 205, 52), intArrayOf(0, 201, 55), intArrayOf(0, 195, 59), intArrayOf(0, 190, 64), intArrayOf(1, 186, 69), intArrayOf(0, 180, 77), intArrayOf(0, 175, 82), intArrayOf(0, 170, 84), intArrayOf(0, 165, 89), intArrayOf(1, 160, 94), intArrayOf(0, 155, 98), intArrayOf(0, 150, 105), intArrayOf(0, 146, 110), intArrayOf(0, 140, 114), intArrayOf(0, 135, 120), intArrayOf(0, 131, 126), intArrayOf(0, 125, 131), intArrayOf(0, 121, 136), intArrayOf(0, 115, 140), intArrayOf(0, 110, 143), intArrayOf(0, 106, 148), intArrayOf(0, 99, 155), intArrayOf(0, 95, 161), intArrayOf(0, 90, 166), intArrayOf(0, 84, 170), intArrayOf(1, 80, 173), intArrayOf(1, 76, 178), intArrayOf(0, 70, 184), intArrayOf(0, 66, 189), intArrayOf(0, 58, 196), intArrayOf(0, 55, 200), intArrayOf(0, 51, 205), intArrayOf(0, 45, 208), intArrayOf(0, 41, 215), intArrayOf(0, 36, 220), intArrayOf(0, 29, 227), intArrayOf(0, 25, 232), intArrayOf(0, 20, 237), intArrayOf(0, 15, 240), intArrayOf(0, 11, 243), intArrayOf(2, 7, 246), intArrayOf(3, 5, 248), intArrayOf(5, 3, 252), intArrayOf(7, 2, 254), intArrayOf(10, 0, 255), intArrayOf(13, 0, 255), intArrayOf(17, 0, 255), intArrayOf(20, 0, 255), intArrayOf(23, 0, 254), intArrayOf(26, 0, 255), intArrayOf(30, 0, 254), intArrayOf(33, 0, 253), intArrayOf(37, 0, 254), intArrayOf(40, 0, 255), intArrayOf(43, 0, 255), intArrayOf(47, 0, 255), intArrayOf(51, 0, 255), intArrayOf(53, 0, 255), intArrayOf(57, 0, 255), intArrayOf(60, 0, 254), intArrayOf(63, 0, 255), intArrayOf(66, 1, 255), intArrayOf(70, 0, 255), intArrayOf(73, 0, 255), intArrayOf(77, 0, 254), intArrayOf(81, 0, 255), intArrayOf(85, 0, 254), intArrayOf(87, 0, 253), intArrayOf(91, 0, 254), intArrayOf(93, 0, 255), intArrayOf(97, 0, 255), intArrayOf(100, 0, 255), intArrayOf(103, 0, 255), intArrayOf(107, 0, 255), intArrayOf(111, 0, 255), intArrayOf(113, 0, 254), intArrayOf(117, 0, 255), intArrayOf(120, 0, 255), intArrayOf(123, 0, 255), intArrayOf(127, 0, 255), intArrayOf(131, 0, 254), intArrayOf(135, 0, 255), intArrayOf(139, 0, 254), intArrayOf(141, 0, 254), intArrayOf(144, 0, 255), intArrayOf(147, 0, 255), intArrayOf(150, 0, 255), intArrayOf(152, 1, 255), intArrayOf(156, 1, 255), intArrayOf(161, 0, 255), intArrayOf(165, 0, 255), intArrayOf(167, 0, 254), intArrayOf(170, 0, 255) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/SpringLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.SpringLUT * @author: Tony Shen * @date: 2024/6/17 14:46 * @version: V1.0 <描述当前版本功能> */ object SpringLUT { var SPRING_LUT = arrayOf( intArrayOf(254, 0, 255), intArrayOf(255, 1, 255), intArrayOf(255, 1, 255), intArrayOf(255, 3, 252), intArrayOf(255, 3, 251), intArrayOf(255, 5, 248), intArrayOf(255, 6, 248), intArrayOf(255, 7, 247), intArrayOf(255, 8, 245), intArrayOf(255, 9, 246), intArrayOf(254, 10, 245), intArrayOf(255, 12, 244), intArrayOf(254, 12, 244), intArrayOf(254, 13, 242), intArrayOf(255, 14, 241), intArrayOf(255, 14, 240), intArrayOf(255, 16, 239), intArrayOf(255, 17, 237), intArrayOf(255, 18, 236), intArrayOf(255, 18, 236), intArrayOf(255, 20, 235), intArrayOf(255, 21, 234), intArrayOf(255, 22, 235), intArrayOf(255, 23, 231), intArrayOf(254, 25, 232), intArrayOf(254, 25, 229), intArrayOf(255, 26, 230), intArrayOf(255, 27, 228), intArrayOf(255, 29, 227), intArrayOf(255, 29, 227), intArrayOf(255, 30, 226), intArrayOf(255, 30, 225), intArrayOf(255, 31, 225), intArrayOf(255, 33, 222), intArrayOf(254, 34, 222), intArrayOf(255, 35, 219), intArrayOf(254, 36, 219), intArrayOf(255, 38, 217), intArrayOf(253, 38, 217), intArrayOf(254, 40, 216), intArrayOf(255, 39, 214), intArrayOf(255, 40, 215), intArrayOf(255, 41, 214), intArrayOf(255, 43, 213), intArrayOf(255, 43, 213), intArrayOf(255, 45, 210), intArrayOf(255, 46, 210), intArrayOf(255, 48, 208), intArrayOf(254, 48, 208), intArrayOf(254, 49, 206), intArrayOf(255, 50, 205), intArrayOf(255, 50, 203), intArrayOf(255, 51, 204), intArrayOf(255, 52, 201), intArrayOf(255, 54, 202), intArrayOf(255, 54, 200), intArrayOf(255, 56, 199), intArrayOf(255, 56, 198), intArrayOf(255, 58, 199), intArrayOf(255, 59, 195), intArrayOf(255, 61, 196), intArrayOf(255, 61, 194), intArrayOf(255, 62, 193), intArrayOf(255, 63, 192), intArrayOf(255, 65, 191), intArrayOf(255, 65, 189), intArrayOf(255, 67, 188), intArrayOf(255, 67, 188), intArrayOf(253, 68, 187), intArrayOf(254, 69, 186), intArrayOf(253, 70, 186), intArrayOf(254, 72, 183), intArrayOf(255, 71, 183), intArrayOf(255, 73, 181), intArrayOf(255, 74, 181), intArrayOf(255, 75, 180), intArrayOf(255, 76, 178), intArrayOf(255, 77, 179), intArrayOf(254, 78, 177), intArrayOf(255, 79, 177), intArrayOf(254, 80, 177), intArrayOf(255, 82, 174), intArrayOf(255, 83, 175), intArrayOf(255, 83, 172), intArrayOf(255, 83, 172), intArrayOf(255, 85, 169), intArrayOf(255, 86, 169), intArrayOf(255, 86, 167), intArrayOf(255, 88, 166), intArrayOf(255, 88, 166), intArrayOf(255, 90, 166), intArrayOf(255, 91, 164), intArrayOf(254, 92, 165), intArrayOf(254, 93, 161), intArrayOf(255, 94, 162), intArrayOf(255, 95, 159), intArrayOf(255, 96, 160), intArrayOf(255, 97, 158), intArrayOf(255, 98, 157), intArrayOf(255, 98, 156), intArrayOf(255, 100, 157), intArrayOf(255, 101, 155), intArrayOf(255, 103, 154), intArrayOf(255, 103, 152), intArrayOf(255, 105, 151), intArrayOf(255, 105, 150), intArrayOf(255, 106, 148), intArrayOf(255, 107, 147), intArrayOf(255, 109, 148), intArrayOf(255, 109, 146), intArrayOf(255, 109, 145), intArrayOf(255, 110, 146), intArrayOf(255, 111, 144), intArrayOf(255, 113, 143), intArrayOf(254, 114, 143), intArrayOf(255, 115, 141), intArrayOf(254, 116, 139), intArrayOf(255, 118, 136), intArrayOf(254, 119, 136), intArrayOf(255, 120, 135), intArrayOf(255, 120, 134), intArrayOf(255, 121, 135), intArrayOf(255, 122, 133), intArrayOf(255, 122, 131), intArrayOf(255, 124, 132), intArrayOf(255, 125, 131), intArrayOf(255, 126, 130), intArrayOf(255, 127, 128), intArrayOf(254, 129, 127), intArrayOf(254, 129, 125), intArrayOf(255, 130, 124), intArrayOf(255, 131, 123), intArrayOf(255, 132, 124), intArrayOf(255, 132, 122), intArrayOf(255, 134, 121), intArrayOf(255, 134, 121), intArrayOf(255, 136, 120), intArrayOf(255, 136, 119), intArrayOf(255, 138, 120), intArrayOf(255, 140, 117), intArrayOf(255, 141, 115), intArrayOf(255, 142, 112), intArrayOf(255, 142, 112), intArrayOf(255, 143, 111), intArrayOf(255, 143, 109), intArrayOf(255, 145, 110), intArrayOf(255, 145, 108), intArrayOf(255, 147, 108), intArrayOf(254, 148, 108), intArrayOf(255, 149, 107), intArrayOf(255, 150, 105), intArrayOf(255, 151, 104), intArrayOf(255, 151, 103), intArrayOf(255, 153, 102), intArrayOf(255, 154, 100), intArrayOf(255, 155, 99), intArrayOf(255, 156, 99), intArrayOf(255, 157, 98), intArrayOf(254, 158, 97), intArrayOf(255, 159, 98), intArrayOf(254, 160, 96), intArrayOf(254, 161, 94), intArrayOf(255, 162, 95), intArrayOf(255, 162, 92), intArrayOf(255, 164, 91), intArrayOf(255, 164, 87), intArrayOf(255, 166, 88), intArrayOf(255, 167, 87), intArrayOf(255, 169, 86), intArrayOf(255, 169, 86), intArrayOf(255, 171, 85), intArrayOf(255, 171, 83), intArrayOf(254, 173, 84), intArrayOf(254, 173, 82), intArrayOf(255, 174, 82), intArrayOf(255, 175, 80), intArrayOf(255, 177, 79), intArrayOf(255, 177, 77), intArrayOf(255, 178, 77), intArrayOf(255, 178, 77), intArrayOf(254, 180, 75), intArrayOf(255, 181, 74), intArrayOf(254, 182, 74), intArrayOf(255, 183, 72), intArrayOf(254, 184, 72), intArrayOf(255, 186, 69), intArrayOf(255, 186, 69), intArrayOf(255, 187, 68), intArrayOf(254, 188, 66), intArrayOf(255, 189, 67), intArrayOf(255, 189, 66), intArrayOf(255, 191, 65), intArrayOf(255, 192, 63), intArrayOf(255, 193, 62), intArrayOf(254, 194, 61), intArrayOf(255, 196, 60), intArrayOf(254, 196, 60), intArrayOf(254, 197, 56), intArrayOf(254, 199, 57), intArrayOf(254, 199, 55), intArrayOf(255, 200, 55), intArrayOf(255, 200, 53), intArrayOf(255, 202, 54), intArrayOf(255, 202, 50), intArrayOf(255, 204, 51), intArrayOf(255, 204, 50), intArrayOf(255, 207, 49), intArrayOf(255, 207, 47), intArrayOf(254, 209, 48), intArrayOf(254, 209, 45), intArrayOf(255, 210, 46), intArrayOf(255, 211, 42), intArrayOf(255, 212, 43), intArrayOf(255, 212, 41), intArrayOf(255, 214, 40), intArrayOf(255, 214, 40), intArrayOf(255, 215, 39), intArrayOf(255, 217, 38), intArrayOf(254, 217, 38), intArrayOf(255, 219, 35), intArrayOf(254, 220, 35), intArrayOf(255, 222, 33), intArrayOf(255, 222, 33), intArrayOf(255, 223, 30), intArrayOf(254, 224, 30), intArrayOf(255, 225, 29), intArrayOf(255, 226, 28), intArrayOf(255, 227, 29), intArrayOf(255, 228, 27), intArrayOf(255, 229, 26), intArrayOf(255, 230, 26), intArrayOf(255, 231, 24), intArrayOf(254, 232, 24), intArrayOf(255, 234, 21), intArrayOf(254, 235, 21), intArrayOf(254, 235, 19), intArrayOf(255, 236, 19), intArrayOf(255, 237, 20), intArrayOf(254, 238, 18), intArrayOf(254, 239, 16), intArrayOf(254, 241, 15), intArrayOf(254, 241, 13), intArrayOf(255, 242, 13), intArrayOf(255, 243, 11), intArrayOf(255, 244, 12), intArrayOf(255, 244, 8), intArrayOf(255, 246, 9), intArrayOf(255, 247, 8), intArrayOf(255, 249, 7), intArrayOf(255, 249, 5), intArrayOf(255, 251, 6), intArrayOf(255, 252, 3), intArrayOf(254, 253, 3), intArrayOf(254, 253, 2), intArrayOf(254, 254, 0), intArrayOf(255, 255, 1) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/SummerLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.SummerLUT * @author: Tony Shen * @date: 2024/6/17 14:51 * @version: V1.0 <描述当前版本功能> */ object SummerLUT { var SUMMER_LUT = arrayOf( intArrayOf(0, 129, 101), intArrayOf(0, 129, 101), intArrayOf(1, 128, 101), intArrayOf(2, 129, 102), intArrayOf(3, 130, 103), intArrayOf(3, 130, 103), intArrayOf(6, 130, 102), intArrayOf(7, 131, 103), intArrayOf(8, 131, 102), intArrayOf(9, 132, 103), intArrayOf(11, 132, 101), intArrayOf(12, 133, 102), intArrayOf(12, 133, 100), intArrayOf(13, 134, 101), intArrayOf(14, 133, 101), intArrayOf(15, 134, 102), intArrayOf(16, 136, 101), intArrayOf(17, 137, 102), intArrayOf(17, 137, 102), intArrayOf(18, 138, 103), intArrayOf(19, 137, 102), intArrayOf(20, 138, 103), intArrayOf(22, 138, 101), intArrayOf(23, 139, 102), intArrayOf(24, 138, 102), intArrayOf(25, 139, 103), intArrayOf(26, 141, 102), intArrayOf(26, 141, 102), intArrayOf(29, 141, 101), intArrayOf(30, 142, 102), intArrayOf(30, 142, 102), intArrayOf(30, 142, 102), intArrayOf(32, 143, 101), intArrayOf(32, 143, 101), intArrayOf(33, 144, 102), intArrayOf(34, 145, 103), intArrayOf(36, 145, 103), intArrayOf(36, 145, 103), intArrayOf(37, 147, 102), intArrayOf(38, 148, 103), intArrayOf(40, 147, 101), intArrayOf(41, 148, 102), intArrayOf(42, 148, 100), intArrayOf(43, 149, 101), intArrayOf(43, 149, 101), intArrayOf(44, 150, 102), intArrayOf(44, 150, 102), intArrayOf(45, 151, 102), intArrayOf(48, 152, 103), intArrayOf(49, 153, 102), intArrayOf(50, 152, 102), intArrayOf(51, 153, 103), intArrayOf(53, 153, 103), intArrayOf(54, 154, 104), intArrayOf(55, 153, 102), intArrayOf(56, 154, 103), intArrayOf(55, 155, 101), intArrayOf(56, 156, 102), intArrayOf(58, 157, 102), intArrayOf(58, 157, 102), intArrayOf(60, 158, 101), intArrayOf(61, 159, 102), intArrayOf(63, 158, 102), intArrayOf(63, 158, 102), intArrayOf(64, 159, 101), intArrayOf(65, 160, 102), intArrayOf(66, 159, 102), intArrayOf(67, 160, 103), intArrayOf(67, 161, 101), intArrayOf(68, 162, 102), intArrayOf(71, 162, 101), intArrayOf(72, 163, 102), intArrayOf(72, 163, 102), intArrayOf(73, 164, 103), intArrayOf(74, 164, 102), intArrayOf(75, 165, 103), intArrayOf(75, 165, 101), intArrayOf(76, 166, 102), intArrayOf(77, 166, 102), intArrayOf(78, 167, 103), intArrayOf(81, 167, 102), intArrayOf(81, 167, 102), intArrayOf(82, 169, 101), intArrayOf(83, 170, 102), intArrayOf(85, 170, 102), intArrayOf(85, 170, 102), intArrayOf(85, 171, 100), intArrayOf(86, 172, 101), intArrayOf(88, 171, 103), intArrayOf(89, 172, 104), intArrayOf(91, 172, 103), intArrayOf(91, 172, 103), intArrayOf(92, 174, 102), intArrayOf(93, 175, 103), intArrayOf(94, 176, 102), intArrayOf(94, 176, 102), intArrayOf(95, 176, 100), intArrayOf(96, 177, 101), intArrayOf(99, 177, 102), intArrayOf(99, 177, 102), intArrayOf(100, 176, 101), intArrayOf(101, 177, 102), intArrayOf(104, 178, 103), intArrayOf(104, 178, 103), intArrayOf(103, 180, 102), intArrayOf(104, 181, 103), intArrayOf(106, 180, 101), intArrayOf(107, 181, 102), intArrayOf(108, 181, 100), intArrayOf(109, 182, 101), intArrayOf(110, 181, 101), intArrayOf(111, 182, 102), intArrayOf(112, 184, 102), intArrayOf(112, 184, 102), intArrayOf(115, 184, 103), intArrayOf(116, 185, 104), intArrayOf(117, 186, 103), intArrayOf(117, 186, 103), intArrayOf(118, 186, 101), intArrayOf(119, 187, 102), intArrayOf(119, 187, 100), intArrayOf(120, 188, 101), intArrayOf(122, 188, 100), intArrayOf(123, 189, 101), intArrayOf(124, 190, 102), intArrayOf(125, 191, 103), intArrayOf(126, 190, 103), intArrayOf(127, 191, 104), intArrayOf(128, 191, 102), intArrayOf(129, 192, 103), intArrayOf(130, 193, 102), intArrayOf(130, 193, 102), intArrayOf(132, 193, 100), intArrayOf(133, 194, 101), intArrayOf(134, 195, 100), intArrayOf(134, 195, 100), intArrayOf(136, 195, 103), intArrayOf(137, 196, 104), intArrayOf(139, 196, 102), intArrayOf(140, 197, 103), intArrayOf(140, 197, 102), intArrayOf(141, 198, 103), intArrayOf(141, 198, 101), intArrayOf(142, 199, 102), intArrayOf(143, 199, 100), intArrayOf(144, 200, 101), intArrayOf(146, 200, 102), intArrayOf(147, 201, 103), intArrayOf(149, 201, 101), intArrayOf(150, 202, 102), intArrayOf(151, 201, 102), intArrayOf(152, 202, 103), intArrayOf(152, 204, 103), intArrayOf(152, 204, 103), intArrayOf(153, 204, 101), intArrayOf(154, 205, 102), intArrayOf(157, 206, 101), intArrayOf(157, 206, 101), intArrayOf(159, 206, 102), intArrayOf(160, 207, 103), intArrayOf(160, 207, 101), intArrayOf(161, 208, 102), intArrayOf(163, 208, 103), intArrayOf(163, 208, 103), intArrayOf(163, 209, 101), intArrayOf(164, 210, 102), intArrayOf(167, 210, 102), intArrayOf(167, 210, 102), intArrayOf(168, 212, 101), intArrayOf(169, 213, 102), intArrayOf(170, 212, 100), intArrayOf(171, 213, 101), intArrayOf(171, 213, 101), intArrayOf(172, 214, 102), intArrayOf(174, 214, 102), intArrayOf(175, 215, 103), intArrayOf(176, 214, 101), intArrayOf(177, 215, 102), intArrayOf(178, 217, 102), intArrayOf(179, 218, 103), intArrayOf(180, 217, 101), intArrayOf(181, 218, 102), intArrayOf(181, 219, 100), intArrayOf(182, 220, 101), intArrayOf(185, 220, 104), intArrayOf(185, 220, 104), intArrayOf(186, 219, 102), intArrayOf(187, 220, 103), intArrayOf(188, 222, 102), intArrayOf(188, 222, 102), intArrayOf(189, 223, 102), intArrayOf(190, 224, 103), intArrayOf(192, 224, 101), intArrayOf(193, 225, 102), intArrayOf(194, 224, 102), intArrayOf(195, 225, 103), intArrayOf(195, 225, 101), intArrayOf(196, 226, 102), intArrayOf(197, 225, 102), intArrayOf(198, 226, 103), intArrayOf(201, 227, 102), intArrayOf(202, 228, 103), intArrayOf(202, 228, 101), intArrayOf(203, 229, 102), intArrayOf(204, 229, 101), intArrayOf(205, 230, 102), intArrayOf(205, 230, 102), intArrayOf(206, 231, 103), intArrayOf(208, 231, 101), intArrayOf(209, 232, 102), intArrayOf(211, 232, 103), intArrayOf(211, 232, 103), intArrayOf(212, 233, 102), intArrayOf(213, 234, 103), intArrayOf(215, 235, 102), intArrayOf(215, 235, 102), intArrayOf(216, 236, 102), intArrayOf(216, 236, 102), intArrayOf(218, 236, 100), intArrayOf(219, 237, 101), intArrayOf(220, 238, 102), intArrayOf(220, 238, 102), intArrayOf(222, 238, 103), intArrayOf(223, 239, 104), intArrayOf(225, 239, 102), intArrayOf(226, 240, 103), intArrayOf(226, 240, 101), intArrayOf(227, 241, 102), intArrayOf(228, 241, 101), intArrayOf(229, 242, 102), intArrayOf(229, 242, 100), intArrayOf(230, 243, 101), intArrayOf(232, 243, 104), intArrayOf(233, 244, 105), intArrayOf(235, 244, 103), intArrayOf(236, 245, 104), intArrayOf(237, 244, 102), intArrayOf(238, 245, 103), intArrayOf(238, 246, 101), intArrayOf(239, 247, 102), intArrayOf(239, 247, 100), intArrayOf(240, 248, 101), intArrayOf(241, 249, 102), intArrayOf(241, 249, 102), intArrayOf(244, 250, 102), intArrayOf(245, 251, 103), intArrayOf(247, 251, 104), intArrayOf(247, 251, 104), intArrayOf(248, 251, 102), intArrayOf(249, 252, 103), intArrayOf(249, 252, 101), intArrayOf(250, 253, 102), intArrayOf(252, 253, 100), intArrayOf(253, 254, 101), intArrayOf(254, 255, 102), intArrayOf(255, 255, 103) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/lut/WinterLUT.kt ================================================ package cn.netdiscovery.monica.imageprocess.lut /** * * @FileName: * cn.netdiscovery.monica.imageprocess.lut.WinterLUT * @author: Tony Shen * @date: 2024/6/17 15:09 * @version: V1.0 <描述当前版本功能> */ object WinterLUT { var WINTER_LUT = arrayOf( intArrayOf(0, 0, 254), intArrayOf(1, 1, 255), intArrayOf(0, 2, 253), intArrayOf(0, 4, 253), intArrayOf(0, 4, 253), intArrayOf(1, 5, 252), intArrayOf(0, 6, 250), intArrayOf(1, 7, 251), intArrayOf(0, 8, 251), intArrayOf(0, 9, 252), intArrayOf(0, 10, 250), intArrayOf(0, 11, 249), intArrayOf(0, 12, 249), intArrayOf(0, 13, 249), intArrayOf(0, 14, 247), intArrayOf(1, 15, 246), intArrayOf(0, 17, 247), intArrayOf(0, 17, 245), intArrayOf(0, 19, 246), intArrayOf(0, 19, 246), intArrayOf(1, 20, 246), intArrayOf(1, 20, 246), intArrayOf(0, 22, 245), intArrayOf(0, 22, 243), intArrayOf(0, 24, 244), intArrayOf(0, 25, 242), intArrayOf(0, 27, 241), intArrayOf(0, 27, 241), intArrayOf(0, 29, 241), intArrayOf(0, 29, 241), intArrayOf(0, 30, 240), intArrayOf(0, 30, 238), intArrayOf(0, 32, 239), intArrayOf(1, 33, 238), intArrayOf(0, 34, 238), intArrayOf(0, 35, 238), intArrayOf(0, 35, 238), intArrayOf(1, 37, 237), intArrayOf(0, 38, 235), intArrayOf(1, 39, 234), intArrayOf(0, 40, 234), intArrayOf(1, 41, 234), intArrayOf(0, 42, 232), intArrayOf(0, 43, 233), intArrayOf(0, 44, 233), intArrayOf(0, 45, 234), intArrayOf(0, 45, 232), intArrayOf(1, 47, 231), intArrayOf(0, 48, 232), intArrayOf(0, 49, 230), intArrayOf(0, 51, 230), intArrayOf(0, 51, 228), intArrayOf(1, 52, 229), intArrayOf(1, 53, 227), intArrayOf(1, 55, 226), intArrayOf(1, 55, 226), intArrayOf(0, 56, 227), intArrayOf(0, 56, 227), intArrayOf(0, 58, 227), intArrayOf(0, 59, 225), intArrayOf(0, 60, 226), intArrayOf(0, 61, 224), intArrayOf(0, 62, 223), intArrayOf(0, 62, 221), intArrayOf(0, 64, 222), intArrayOf(0, 65, 221), intArrayOf(0, 66, 222), intArrayOf(1, 67, 223), intArrayOf(1, 68, 221), intArrayOf(1, 68, 219), intArrayOf(0, 70, 220), intArrayOf(1, 71, 219), intArrayOf(0, 72, 218), intArrayOf(1, 73, 219), intArrayOf(0, 74, 217), intArrayOf(0, 75, 218), intArrayOf(0, 76, 216), intArrayOf(0, 77, 217), intArrayOf(0, 77, 215), intArrayOf(1, 78, 216), intArrayOf(0, 80, 215), intArrayOf(0, 81, 216), intArrayOf(0, 83, 215), intArrayOf(0, 83, 215), intArrayOf(0, 83, 213), intArrayOf(1, 84, 214), intArrayOf(1, 86, 213), intArrayOf(1, 86, 211), intArrayOf(0, 88, 212), intArrayOf(0, 88, 211), intArrayOf(0, 90, 210), intArrayOf(0, 90, 210), intArrayOf(0, 93, 209), intArrayOf(0, 93, 209), intArrayOf(0, 94, 208), intArrayOf(0, 94, 207), intArrayOf(0, 96, 208), intArrayOf(1, 97, 207), intArrayOf(0, 98, 207), intArrayOf(1, 99, 206), intArrayOf(1, 99, 206), intArrayOf(2, 101, 205), intArrayOf(0, 102, 203), intArrayOf(1, 103, 203), intArrayOf(0, 104, 203), intArrayOf(1, 105, 202), intArrayOf(0, 106, 200), intArrayOf(0, 107, 201), intArrayOf(0, 108, 201), intArrayOf(0, 109, 202), intArrayOf(0, 109, 200), intArrayOf(1, 111, 200), intArrayOf(0, 111, 200), intArrayOf(1, 113, 199), intArrayOf(0, 114, 197), intArrayOf(0, 115, 196), intArrayOf(1, 116, 197), intArrayOf(1, 116, 196), intArrayOf(1, 118, 195), intArrayOf(1, 118, 195), intArrayOf(0, 120, 196), intArrayOf(0, 120, 196), intArrayOf(0, 122, 195), intArrayOf(0, 123, 193), intArrayOf(0, 124, 194), intArrayOf(0, 125, 192), intArrayOf(1, 126, 192), intArrayOf(1, 126, 190), intArrayOf(0, 128, 191), intArrayOf(0, 128, 189), intArrayOf(0, 130, 190), intArrayOf(0, 130, 190), intArrayOf(1, 131, 189), intArrayOf(2, 132, 190), intArrayOf(0, 133, 189), intArrayOf(1, 135, 188), intArrayOf(0, 136, 188), intArrayOf(1, 137, 187), intArrayOf(0, 138, 185), intArrayOf(1, 139, 186), intArrayOf(0, 140, 185), intArrayOf(0, 141, 186), intArrayOf(0, 141, 184), intArrayOf(0, 143, 183), intArrayOf(0, 143, 183), intArrayOf(0, 145, 182), intArrayOf(0, 145, 182), intArrayOf(0, 147, 181), intArrayOf(0, 149, 182), intArrayOf(0, 149, 181), intArrayOf(0, 151, 180), intArrayOf(0, 151, 178), intArrayOf(1, 152, 179), intArrayOf(1, 153, 177), intArrayOf(0, 155, 177), intArrayOf(0, 155, 177), intArrayOf(0, 156, 178), intArrayOf(0, 156, 178), intArrayOf(1, 158, 177), intArrayOf(0, 159, 175), intArrayOf(0, 160, 176), intArrayOf(0, 161, 174), intArrayOf(0, 162, 173), intArrayOf(0, 163, 172), intArrayOf(0, 164, 173), intArrayOf(0, 165, 171), intArrayOf(1, 166, 170), intArrayOf(2, 167, 171), intArrayOf(0, 168, 171), intArrayOf(1, 169, 172), intArrayOf(0, 170, 170), intArrayOf(1, 171, 170), intArrayOf(0, 172, 170), intArrayOf(0, 173, 169), intArrayOf(0, 174, 167), intArrayOf(0, 176, 166), intArrayOf(0, 176, 166), intArrayOf(0, 178, 166), intArrayOf(0, 178, 166), intArrayOf(0, 178, 166), intArrayOf(0, 180, 165), intArrayOf(0, 181, 166), intArrayOf(0, 183, 165), intArrayOf(0, 183, 163), intArrayOf(0, 185, 164), intArrayOf(0, 185, 162), intArrayOf(0, 187, 162), intArrayOf(0, 187, 162), intArrayOf(0, 189, 161), intArrayOf(0, 189, 161), intArrayOf(0, 191, 160), intArrayOf(0, 191, 158), intArrayOf(0, 193, 159), intArrayOf(0, 193, 158), intArrayOf(0, 194, 159), intArrayOf(0, 194, 157), intArrayOf(0, 196, 158), intArrayOf(0, 196, 156), intArrayOf(0, 198, 155), intArrayOf(0, 199, 153), intArrayOf(0, 200, 154), intArrayOf(0, 202, 154), intArrayOf(0, 203, 152), intArrayOf(0, 204, 153), intArrayOf(0, 204, 153), intArrayOf(1, 205, 154), intArrayOf(0, 206, 152), intArrayOf(0, 207, 151), intArrayOf(0, 208, 151), intArrayOf(0, 209, 151), intArrayOf(0, 210, 149), intArrayOf(1, 211, 148), intArrayOf(0, 212, 148), intArrayOf(0, 213, 147), intArrayOf(0, 214, 146), intArrayOf(0, 215, 147), intArrayOf(0, 217, 148), intArrayOf(0, 217, 148), intArrayOf(0, 219, 147), intArrayOf(0, 219, 145), intArrayOf(0, 220, 146), intArrayOf(0, 221, 144), intArrayOf(0, 223, 143), intArrayOf(0, 223, 142), intArrayOf(0, 225, 143), intArrayOf(0, 225, 141), intArrayOf(0, 226, 140), intArrayOf(0, 226, 140), intArrayOf(0, 228, 141), intArrayOf(0, 228, 141), intArrayOf(0, 230, 140), intArrayOf(0, 231, 139), intArrayOf(0, 232, 140), intArrayOf(0, 234, 139), intArrayOf(0, 235, 137), intArrayOf(0, 236, 136), intArrayOf(0, 236, 136), intArrayOf(1, 237, 136), intArrayOf(0, 238, 136), intArrayOf(0, 239, 135), intArrayOf(0, 240, 135), intArrayOf(0, 241, 134), intArrayOf(0, 242, 132), intArrayOf(1, 243, 133), intArrayOf(0, 244, 133), intArrayOf(1, 245, 134), intArrayOf(0, 246, 132), intArrayOf(0, 247, 132), intArrayOf(0, 249, 133), intArrayOf(0, 249, 131), intArrayOf(0, 251, 130), intArrayOf(0, 251, 128), intArrayOf(0, 252, 129), intArrayOf(0, 253, 128), intArrayOf(0, 254, 129), intArrayOf(0, 254, 129) ) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/math/FFT.kt ================================================ package cn.netdiscovery.monica.imageprocess.math import kotlin.math.max import kotlin.math.sin /** * * @FileName: * cn.netdiscovery.monica.imageprocess.math.FFT * @author: Tony Shen * @date: 2025/3/14 15:36 * @version: V1.0 <描述当前版本功能> */ class FFT(logN: Int) { // Weighting factors protected var w1: FloatArray protected var w2: FloatArray protected var w3: FloatArray init { // Prepare the weighting factors w1 = FloatArray(logN) w2 = FloatArray(logN) w3 = FloatArray(logN) var N = 1 for (k in 0.. j) { var t = real[j] real[j] = real[i] real[i] = t t = imag[j] imag[j] = imag[i] imag[i] = t } var m = n shr 1 while (j >= m && m >= 2) { j -= m m = m shr 1 } j += m } } private fun butterflies(n: Int, logN: Int, direction: Int, real: FloatArray, imag: FloatArray) { var N = 1 for (k in 0.. */ interface Function1D { fun evaluate(x: Float): Float } interface Function2D { fun evaluate(x: Float, y: Float): Float } interface Function3D { fun evaluate(x: Float, y: Float, z: Float): Float } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/math/ImageMath.kt ================================================ package cn.netdiscovery.monica.imageprocess.math /** * * @FileName: * cn.netdiscovery.monica.imageprocess.utils.ImageMath * @author: Tony Shen * @date: 2025/3/8 15:43 * @version: V1.0 <描述当前版本功能> */ val PI: Float = Math.PI.toFloat() val HALF_PI = Math.PI.toFloat() / 2.0f val TWO_PI = Math.PI.toFloat() * 2.0f /** * Return a mod b. This differs from the % operator with respect to negative numbers. * @param a the dividend * @param b the divisor * @return a mod b */ fun mod(a: Double, b: Double): Double { var a = a val n = (a / b).toInt() a -= n * b if (a < 0) return a + b return a } /** * Return a mod b. This differs from the % operator with respect to negative numbers. * @param a the dividend * @param b the divisor * @return a mod b */ fun mod(a: Float, b: Float): Float { var a = a val n = (a / b).toInt() a -= n * b if (a < 0) return a + b return a } /** * Return a mod b. This differs from the % operator with respect to negative numbers. * @param a the dividend * @param b the divisor * @return a mod b */ fun mod(a: Int, b: Int): Int { var a = a val n = a / b a -= n * b if (a < 0) return a + b return a } /** * Linear interpolation of ARGB values. * @param t the interpolation parameter * @param rgb1 the lower interpolation range * @param rgb2 the upper interpolation range * @return the interpolated value */ fun mixColors(t: Float, rgb1: Int, rgb2: Int): Int { var a1 = (rgb1 shr 24) and 0xff var r1 = (rgb1 shr 16) and 0xff var g1 = (rgb1 shr 8) and 0xff var b1 = rgb1 and 0xff val a2 = (rgb2 shr 24) and 0xff val r2 = (rgb2 shr 16) and 0xff val g2 = (rgb2 shr 8) and 0xff val b2 = rgb2 and 0xff a1 = lerp(t, a1, a2) r1 = lerp(t, r1, r2) g1 = lerp(t, g1, g2) b1 = lerp(t, b1, b2) return (a1 shl 24) or (r1 shl 16) or (g1 shl 8) or b1 } /** * Linear interpolation. * @param t the interpolation parameter * @param a the lower interpolation range * @param b the upper interpolation range * @return the interpolated value */ fun lerp(t: Float, a: Int, b: Int): Int { return (a + t * (b - a)).toInt() } /** * Bilinear interpolation of ARGB values. * @param x the X interpolation parameter 0..1 * @param y the y interpolation parameter 0..1 * @param rgb array of four ARGB values in the order NW, NE, SW, SE * @return the interpolated value */ fun bilinearInterpolate(x: Float, y: Float, nw: Int, ne: Int, sw: Int, se: Int): Int { var m0: Float var m1: Float val a0 = (nw shr 24) and 0xff val r0 = (nw shr 16) and 0xff val g0 = (nw shr 8) and 0xff val b0 = nw and 0xff val a1 = (ne shr 24) and 0xff val r1 = (ne shr 16) and 0xff val g1 = (ne shr 8) and 0xff val b1 = ne and 0xff val a2 = (sw shr 24) and 0xff val r2 = (sw shr 16) and 0xff val g2 = (sw shr 8) and 0xff val b2 = sw and 0xff val a3 = (se shr 24) and 0xff val r3 = (se shr 16) and 0xff val g3 = (se shr 8) and 0xff val b3 = se and 0xff val cx = 1.0f - x val cy = 1.0f - y m0 = cx * a0 + x * a1 m1 = cx * a2 + x * a3 val a = (cy * m0 + y * m1).toInt() m0 = cx * r0 + x * r1 m1 = cx * r2 + x * r3 val r = (cy * m0 + y * m1).toInt() m0 = cx * g0 + x * g1 m1 = cx * g2 + x * g3 val g = (cy * m0 + y * m1).toInt() m0 = cx * b0 + x * b1 m1 = cx * b2 + x * b3 val b = (cy * m0 + y * m1).toInt() return (a shl 24) or (r shl 16) or (g shl 8) or b } /** * A smoothed step function. A cubic function is used to smooth the step between two thresholds. * @param a the lower threshold position * @param b the upper threshold position * @param x the input parameter * @return the output value */ fun smoothStep(a: Float, b: Float, x: Float): Float { var x = x if (x < a) return 0f if (x >= b) return 1f x = (x - a) / (b - a) return x * x * (3 - 2 * x) } /** * The triangle function. Returns a repeating triangle shape in the range 0..1 with wavelength 1.0 * @param x the input parameter * @return the output value */ fun triangle(x: Float): Float { val r = mod(x, 1.0f) return 2.0f * (if (r < 0.5) r else 1 - r) } /** * Apply a bias to a number in the unit interval, moving numbers towards 0 or 1 * according to the bias parameter. * @param a the number to bias * @param b the bias parameter. 0.5 means no change, smaller values bias towards 0, larger towards 1. * @return the output value */ fun bias(a: Float, b: Float): Float { return a / ((1.0f / b - 2) * (1.0f - a) + 1) } /** * A variant of the gamma function. * @param a the number to apply gain to * @param b the gain parameter. 0.5 means no change, smaller values reduce gain, larger values increase gain. * @return the output value */ fun gain(a: Float, b: Float): Float { val c = (1.0f / b - 2.0f) * (1.0f - 2.0f * a) return if (a < 0.5) a / (c + 1.0f) else (c - a) / (c - 1.0f) } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/math/Noise.kt ================================================ package cn.netdiscovery.monica.imageprocess.math import java.util.* import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.sqrt /** * Perlin Noise functions * @FileName: * cn.netdiscovery.monica.imageprocess.math.Noise * @author: Tony Shen * @date: 2025/3/10 11:48 * @version: V1.0 <描述当前版本功能> */ class Noise : Function1D, Function2D, Function3D { override fun evaluate(x: Float): Float { return noise1(x) } override fun evaluate(x: Float, y: Float): Float { return noise2(x, y) } override fun evaluate(x: Float, y: Float, z: Float): Float { return noise3(x, y, z) } companion object { private val randomGenerator: Random = Random() /** * Compute turbulence using Perlin noise. * @param x the x value * @param y the y value * @param octaves number of octaves of turbulence * @return turbulence value at (x,y) */ fun turbulence2(x: Float, y: Float, octaves: Float): Float { var t = 0.0f var f = 1.0f while (f <= octaves) { t += abs(noise2(f * x, f * y)) / f f *= 2f } return t } /** * Compute turbulence using Perlin noise. * @param x the x value * @param y the y value * @param octaves number of octaves of turbulence * @return turbulence value at (x,y) */ fun turbulence3(x: Float, y: Float, z: Float, octaves: Float): Float { var t = 0.0f var f = 1.0f while (f <= octaves) { t += abs(noise3(f * x, f * y, f * z)) / f f *= 2f } return t } internal const val B = 0x100 private const val BM = 0xff private const val N = 0x1000 var p: IntArray = IntArray(B + B + 2) var g3: Array = Array(B + B + 2) { FloatArray(3) } var g2: Array = Array(B + B + 2) { FloatArray(2) } var g1: FloatArray = FloatArray(B + B + 2) var start: Boolean = true private fun sCurve(t: Float): Float { return t * t * (3.0f - 2.0f * t) } /** * Compute 1-dimensional Perlin noise. * @param x the x value * @return noise value at x in the range -1..1 */ fun noise1(x: Float): Float { val bx0: Int val rx0: Float val v: Float if (start) { start = false init() } val t = x + N bx0 = (t.toInt()) and BM val bx1 = (bx0 + 1) and BM rx0 = t - t.toInt() val rx1 = rx0 - 1.0f val sx = sCurve(rx0) val u = rx0 * g1[p[bx0]] v = rx1 * g1[p[bx1]] return 2.3f * lerp(sx, u, v) } /** * Compute 2-dimensional Perlin noise. * @param x the x coordinate * @param y the y coordinate * @return noise value at (x,y) */ fun noise2(x: Float, y: Float): Float { val bx0: Int val by0: Int val b00: Int val b10: Int val b01: Int val b11: Int val rx0: Float val ry0: Float val a: Float val b: Float var u: Float var v: Float val j: Int if (start) { start = false init() } var t = x + N bx0 = (t.toInt()) and BM val bx1 = (bx0 + 1) and BM rx0 = t - t.toInt() val rx1 = rx0 - 1.0f t = y + N by0 = (t.toInt()) and BM val by1 = (by0 + 1) and BM ry0 = t - t.toInt() val ry1 = ry0 - 1.0f val i = p[bx0] j = p[bx1] b00 = p[i + by0] b10 = p[j + by0] b01 = p[i + by1] b11 = p[j + by1] val sx = sCurve(rx0) val sy = sCurve(ry0) var q = g2[b00] u = rx0 * q[0] + ry0 * q[1] q = g2[b10] v = rx1 * q[0] + ry0 * q[1] a = lerp(sx, u, v) q = g2[b01] u = rx0 * q[0] + ry1 * q[1] q = g2[b11] v = rx1 * q[0] + ry1 * q[1] b = lerp(sx, u, v) return 1.5f * lerp(sy, a, b) } /** * Compute 3-dimensional Perlin noise. * @param x the x coordinate * @param y the y coordinate * @param y the y coordinate * @return noise value at (x,y,z) */ fun noise3(x: Float, y: Float, z: Float): Float { val bx0: Int val by0: Int val bz0: Int val b00: Int val b10: Int val b01: Int val b11: Int val rx0: Float val ry0: Float val rz0: Float var a: Float var b: Float val c: Float val d: Float var u: Float var v: Float val j: Int if (start) { start = false init() } var t = x + N bx0 = (t.toInt()) and BM val bx1 = (bx0 + 1) and BM rx0 = t - t.toInt() val rx1 = rx0 - 1.0f t = y + N by0 = (t.toInt()) and BM val by1 = (by0 + 1) and BM ry0 = t - t.toInt() val ry1 = ry0 - 1.0f t = z + N bz0 = (t.toInt()) and BM val bz1 = (bz0 + 1) and BM rz0 = t - t.toInt() val rz1 = rz0 - 1.0f val i = p[bx0] j = p[bx1] b00 = p[i + by0] b10 = p[j + by0] b01 = p[i + by1] b11 = p[j + by1] t = sCurve(rx0) val sy = sCurve(ry0) val sz = sCurve(rz0) var q = g3[b00 + bz0] u = rx0 * q[0] + ry0 * q[1] + rz0 * q[2] q = g3[b10 + bz0] v = rx1 * q[0] + ry0 * q[1] + rz0 * q[2] a = lerp(t, u, v) q = g3[b01 + bz0] u = rx0 * q[0] + ry1 * q[1] + rz0 * q[2] q = g3[b11 + bz0] v = rx1 * q[0] + ry1 * q[1] + rz0 * q[2] b = lerp(t, u, v) c = lerp(sy, a, b) q = g3[b00 + bz1] u = rx0 * q[0] + ry0 * q[1] + rz1 * q[2] q = g3[b10 + bz1] v = rx1 * q[0] + ry0 * q[1] + rz1 * q[2] a = lerp(t, u, v) q = g3[b01 + bz1] u = rx0 * q[0] + ry1 * q[1] + rz1 * q[2] q = g3[b11 + bz1] v = rx1 * q[0] + ry1 * q[1] + rz1 * q[2] b = lerp(t, u, v) d = lerp(sy, a, b) return 1.5f * lerp(sz, c, d) } fun lerp(t: Float, a: Float, b: Float): Float { return a + t * (b - a) } private fun normalize2(v: FloatArray) { val s = sqrt((v[0] * v[0] + v[1] * v[1]).toDouble()).toFloat() v[0] = v[0] / s v[1] = v[1] / s } fun normalize3(v: FloatArray) { val s = sqrt((v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).toDouble()).toFloat() v[0] = v[0] / s v[1] = v[1] / s v[2] = v[2] / s } private fun random(): Int { return randomGenerator.nextInt() and 0x7fffffff } private fun init() { var j: Int var k: Int var i = 0 while (i < B) { p[i] = i g1[i] = ((random() % (B + B)) - B).toFloat() / B j = 0 while (j < 2) { g2[i][j] = ((random() % (B + B)) - B).toFloat() / B j++ } normalize2(g2[i]) j = 0 while (j < 3) { g3[i][j] = ((random() % (B + B)) - B).toFloat() / B j++ } normalize3(g3[i]) i++ } i = B - 1 while (i >= 0) { k = p[i] p[i] = p[(random() % B).also { j = it }] p[j] = k i-- } i = 0 while (i < B + 2) { p[B + i] = p[i] g1[B + i] = g1[i] j = 0 while (j < 2) { g2[B + i][j] = g2[i][j] j++ } j = 0 while (j < 3) { g3[B + i][j] = g3[i][j] j++ } i++ } } /** * Returns the minimum and maximum of a number of random values * of the given function. This is useful for making some stab at * normalising the function. */ fun findRange(f: Function1D, minmax: FloatArray?): FloatArray { var minmax = minmax if (minmax == null) minmax = FloatArray(2) var min = 0f var max = 0f // Some random numbers here... var x = -100f while (x < 100) { val n: Float = f.evaluate(x) min = min(min.toDouble(), n.toDouble()).toFloat() max = max(max.toDouble(), n.toDouble()).toFloat() x += 1.27139.toFloat() } minmax[0] = min minmax[1] = max return minmax } /** * Returns the minimum and maximum of a number of random values * of the given function. This is useful for making some stab at * normalising the function. */ fun findRange(f: Function2D, minmax: FloatArray?): FloatArray { var minmax = minmax if (minmax == null) minmax = FloatArray(2) var min = 0f var max = 0f // Some random numbers here... var y = -100f while (y < 100) { var x = -100f while (x < 100) { val n: Float = f.evaluate(x, y) min = min(min.toDouble(), n.toDouble()).toFloat() max = max(max.toDouble(), n.toDouble()).toFloat() x += 10.77139.toFloat() } y += 10.35173.toFloat() } minmax[0] = min minmax[1] = max return minmax } } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/utils/ImageUtils.kt ================================================ package cn.netdiscovery.monica.imageprocess.utils import org.apache.batik.anim.dom.SAXSVGDocumentFactory import org.apache.batik.transcoder.TranscoderInput import org.apache.batik.transcoder.TranscoderOutput import org.apache.batik.transcoder.image.ImageTranscoder import org.apache.batik.util.XMLResourceDescriptor import org.w3c.dom.Document import org.w3c.dom.Element import java.awt.image.BufferedImage import java.io.* import javax.imageio.ImageIO import javax.xml.transform.TransformerFactory import javax.xml.transform.dom.DOMSource import javax.xml.transform.stream.StreamResult /** * * @FileName: * cn.netdiscovery.monica.imageprocess.utils.ImageUtils * @author: Tony Shen * @date: 2025/2/21 18:16 * @version: V1.0 <描述当前版本功能> */ /** * 把 BufferedImage 转换成文件,便于调试时使用 */ @Throws(IOException::class) fun writeImageFile(bi: BufferedImage, fileName:String, formatName:String = "png"):Boolean { return ImageIO.write(bi, formatName, File(fileName)) } fun writeImageFileAsWebP(bi: BufferedImage, fileName:String):Boolean { val writers = ImageIO.getImageWritersByFormatName("webp") if (!writers.hasNext()) { println("不支持 WebP 格式,请确保 webp-imageio 插件已添加。") return false } val writer = writers.next() val output = ImageIO.createImageOutputStream(File(fileName)) writer.output = output writer.write(null, javax.imageio.IIOImage(bi, null, null), null) output.close() writer.dispose() return true } fun loadAndFixSvg(inputSvgFile: File): Document { val parser = XMLResourceDescriptor.getXMLParserClassName() val factory = SAXSVGDocumentFactory(parser) val doc = factory.createDocument(inputSvgFile.toURI().toString()) val svgNS = "http://www.w3.org/2000/svg" val xlinkNS = "http://www.w3.org/1999/xlink" val useTags = doc.getElementsByTagNameNS(svgNS, "use") val toRemove = mutableListOf() for (i in 0 until useTags.length) { val use = useTags.item(i) as Element val href = use.getAttributeNS(xlinkNS, "href") if (href.isNullOrBlank()) { toRemove.add(use) } } toRemove.forEach { it.parentNode?.removeChild(it) } println("清除非法 标签数: ${toRemove.size}") return doc } fun svgDocumentToBufferedImage(doc: Document, width: Float? = null, height: Float? = null): BufferedImage? { var image: BufferedImage? = null val transcoder = object : ImageTranscoder() { override fun createImage(w: Int, h: Int): BufferedImage { return BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) } override fun writeImage(img: BufferedImage, output: TranscoderOutput?) { image = img } } if (width != null) transcoder.addTranscodingHint(ImageTranscoder.KEY_WIDTH, width) if (height != null) transcoder.addTranscodingHint(ImageTranscoder.KEY_HEIGHT, height) val inputStream = ByteArrayOutputStream().use { baos -> val transformer = TransformerFactory.newInstance().newTransformer() transformer.transform(DOMSource(doc), StreamResult(baos)) ByteArrayInputStream(baos.toByteArray()) } val input = TranscoderInput(inputStream) try { transcoder.transcode(input, null) } catch (e: Exception) { e.printStackTrace() return null } return image } fun loadFixedSvgAsImage(inputFile: File, width: Float? = null, height: Float? = null): BufferedImage? { val doc = loadAndFixSvg(inputFile) return svgDocumentToBufferedImage(doc, width, height) } /** * 在无需解码整张图片的情况下,获取图像的尺寸 */ fun getImageDimension(file: File): Pair? { ImageIO.createImageInputStream(file)?.use { input -> val readers = ImageIO.getImageReaders(input) if (readers.hasNext()) { val reader = readers.next() reader.input = input val width = reader.getWidth(0) val height = reader.getHeight(0) reader.dispose() return width to height } } return null } /** * 判断图像是否大图 */ fun isLargeImage(width: Int, height: Int): Boolean { val pixelCount = width * height val longSide = maxOf(width, height) return pixelCount > 12_000_000 || longSide > 4000 // 1200 万像素或长边超 4000px } fun clamp(c: Int): Int { return if (c > 255) 255 else if (c < 0) 0 else c } fun clamp(x: Int, a: Int, b: Int): Int { return if (x < a) a else if (x > b) b else x } /** * Clamp a value to an interval. * @param a the lower clamp threshold * @param b the upper clamp threshold * @param x the input parameter * @return the clamped value */ fun clamp(x: Float, a: Float, b: Float): Float { return if (x < a) a else if (x > b) b else x } fun premultiply(p: IntArray, offset: Int, length: Int) { var length = length length += offset for (i in offset until length) { val rgb = p[i] val a = (rgb shr 24) and 0xff var r = (rgb shr 16) and 0xff var g = (rgb shr 8) and 0xff var b = rgb and 0xff val f = a * (1.0f / 255.0f) r = (r * f).toInt() g = (g * f).toInt() b = (b * f).toInt() p[i] = (a shl 24) or (r shl 16) or (g shl 8) or b } } fun unpremultiply(p: IntArray, offset: Int, length: Int) { var length = length length += offset for (i in offset until length) { val rgb = p[i] val a = (rgb shr 24) and 0xff var r = (rgb shr 16) and 0xff var g = (rgb shr 8) and 0xff var b = rgb and 0xff if (a != 0 && a != 255) { val f = 255.0f / a r = (r * f).toInt() g = (g * f).toInt() b = (b * f).toInt() if (r > 255) r = 255 if (g > 255) g = 255 if (b > 255) b = 255 p[i] = (a shl 24) or (r shl 16) or (g shl 8) or b } } } ================================================ FILE: imageprocess/src/main/kotlin/cn/netdiscovery/monica/imageprocess/utils/extension/BufferedImage+Extensions.kt ================================================ package cn.netdiscovery.monica.imageprocess.utils.extension import cn.netdiscovery.monica.imageprocess.ImageInfo import java.awt.Color import java.awt.Image import java.awt.RenderingHints import java.awt.geom.AffineTransform import java.awt.image.BufferedImage import java.io.ByteArrayOutputStream import javax.imageio.ImageIO import kotlin.math.abs import kotlin.math.cos import kotlin.math.floor import kotlin.math.sin /** * * @FileName: * cn.netdiscovery.monica.imageprocess.utils.extension.`BufferedImage+Extension` * @author: Tony Shen * @date: 2025/2/22 15:00 * @version: V1.0 <描述当前版本功能> */ fun BufferedImage.image2ByteArray() : ByteArray { val outStream = ByteArrayOutputStream() ImageIO.write(this, "png", outStream) return outStream.toByteArray() } fun BufferedImage.toImageInfo(): ImageInfo { val width = this.width val height = this.height val byteArray = this.image2ByteArray() return ImageInfo(width,height,byteArray) } /** * 对两个图像按像素进行比较 */ fun BufferedImage.isEqualTo(image: BufferedImage): Boolean { if (width != image.width || height != image.height) return false for (y in 0 until height) { for (x in 0 until width) { if (getRGB(x, y) != image.getRGB(x, y)) return false } } return true } /** * 对图像裁剪 ROI 区域 */ fun BufferedImage.subImage(x: Int, y: Int, w: Int, h: Int): BufferedImage { if (w < 0 || h < 0) throw IllegalArgumentException("Width and height should be non-negative: ($w; $h)") var x1 = x var x2 = x + w // w >= 0 => x1 <= x2 x1 = x1.coerceIn(0, width) x2 = x2.coerceIn(0, width) var y1 = y var y2 = y + h // h >= 0 => y1 <= y2 y1 = y1.coerceIn(0, height) y2 = y2.coerceIn(0, height) if (x2 - x1 == 0 || y2 - y1 == 0) return BufferedImage(1, 1, this.type) return getSubimage(x1, y1, x2 - x1, y2 - y1) } /** * 对图像水平翻转 */ fun BufferedImage.flipHorizontally(): BufferedImage { val flipped = BufferedImage(width, height, type) val tran = AffineTransform.getTranslateInstance(width.toDouble(), 0.0) val flip = AffineTransform.getScaleInstance(-1.0, 1.0) tran.concatenate(flip) val g = flipped.createGraphics() g.transform = tran g.drawImage(this, 0, 0, null) g.dispose() return flipped } /** * 图像旋转 */ fun BufferedImage.rotate(angle: Double): BufferedImage { val radian = Math.toRadians(angle) val sin = abs(sin(radian)) val cos = abs(cos(radian)) val newWidth = floor(width.toDouble() * cos + height.toDouble() * sin).toInt() val newHeight = floor(height.toDouble() * cos + width.toDouble() * sin).toInt() val rotatedImage = BufferedImage(newWidth, newHeight, type) val graphics = rotatedImage.createGraphics() graphics.setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC ) graphics.translate((newWidth - width) / 2, (newHeight - height) / 2) // rotation around the center point graphics.rotate(radian, (width / 2).toDouble(), (height / 2).toDouble()) graphics.drawImage(this, 0, 0, null) graphics.dispose() return rotatedImage } /** * 对图像进行缩放 */ fun BufferedImage.resize(width:Int, height:Int): BufferedImage { val tmp = this.getScaledInstance(width, height, Image.SCALE_SMOOTH) val resizedImage = BufferedImage(width, height, type) val g2d = resizedImage.createGraphics() try { g2d.drawImage(tmp, 0, 0, null) } finally { g2d.dispose() } return resizedImage } fun BufferedImage.convertToRGB(): BufferedImage { val rgbImage = BufferedImage(this.width, this.height, BufferedImage.TYPE_INT_RGB) val g = rgbImage.createGraphics() g.drawImage(this, 0, 0, Color.WHITE, null) // 用白色背景填充透明区域 g.dispose() return rgbImage } ================================================ FILE: opencv/build.gradle.kts ================================================ plugins { kotlin("jvm") } repositories { mavenCentral() } dependencies { testImplementation(kotlin("test")) implementation ("org.jetbrains.kotlin:kotlin-stdlib") implementation(project(":config")) implementation(project(":domain")) } tasks.test { useJUnitPlatform() } kotlin { jvmToolchain(17) } ================================================ FILE: opencv/src/main/kotlin/cn/netdiscovery/monica/opencv/ImageProcess.kt ================================================ package cn.netdiscovery.monica.opencv import cn.netdiscovery.monica.config.isMac import cn.netdiscovery.monica.config.isWindows import cn.netdiscovery.monica.domain.* import java.io.File /** * * @FileName: * cn.netdiscovery.monica.opencv.ImageProcess * @author: Tony Shen * @date: 2024/7/14 21:22 * @version: V1.0 <描述当前版本功能> */ object ImageProcess { private val loadPath by lazy{ System.getProperty("compose.application.resources.dir") + File.separator } val resourcesDir by lazy { File(loadPath) } init { // 需要先加载图像处理库,否则无法通过 jni 调用算法 loadMonicaImageProcess() } /** * 对于不同的平台加载的库是不同的: mac 是 dylib 库、windows 是 dll 库、linux 是 so 库 */ private fun loadMonicaImageProcess() { if (isMac) { System.load("${loadPath}libMonicaImageProcess.dylib") } else if (isWindows) { System.load("${loadPath}libraw.dll") System.load("${loadPath}libde265.dll") System.load("${loadPath}aom.dll") System.load("${loadPath}heif.dll") System.load("${loadPath}opencv_world481.dll") System.load("${loadPath}MonicaImageProcess.dll") } else { System.load("${loadPath}libMonicaImageProcess.so") } } /** * 该算法库的版本号 */ external fun getVersion():String /** * 当前使用的 OpenCV 的版本号 */ external fun getOpenCVVersion():String /** * 图像错切 * @param 沿 x 方向 * @param 沿 y 方向 */ external fun shearing(src: ByteArray, x:Float, y:Float):IntArray /** * 初始化图像调色模块 */ external fun initColorCorrection(src: ByteArray): Long /** * 图像调色 */ external fun colorCorrection(src: ByteArray, colorCorrectionSettings: ColorCorrectionSettings, cppObjectPtr:Long):IntArray /** * 删除 ColorCorrection */ external fun deleteColorCorrection(cppObjectPtr:Long): Long /** * 直方图均衡化 */ external fun equalizeHist(src: ByteArray):IntArray /** * 限制对比度自适应直方图均衡 */ external fun clahe(src: ByteArray, clipLimit:Double, size:Int):IntArray /** * gamma 校正 */ external fun gammaCorrection(src: ByteArray,k:Float):IntArray /** * laplace 锐化,主要是 8 邻域卷积核 */ external fun laplaceSharpening(src: ByteArray):IntArray /** * USM 锐化 */ external fun unsharpMask(src: ByteArray, radius:Int, threshold:Int, amount:Int):IntArray /** * 自动色彩均衡 */ external fun ace(src: ByteArray, ratio:Int, radius:Int):IntArray /** * 转换成灰度图像 */ external fun cvtGray(src: ByteArray):IntArray /** * 阈值分割 */ external fun threshold(src: ByteArray, thresholdType1: Int, thresholdType2: Int):IntArray /** * 自适应阈值分割 */ external fun adaptiveThreshold(src: ByteArray, adaptiveMethod: Int, thresholdType: Int, blockSize:Int, c:Int):IntArray /** * 颜色分割 */ external fun inRange(src: ByteArray, hmin:Int, smin:Int, vmin:Int, hmax:Int, smax:Int, vmax:Int):IntArray /** * 实现 roberts 算子 */ external fun roberts(src: ByteArray):IntArray /** * 实现 prewitt 算子 */ external fun prewitt(src: ByteArray):IntArray /** * 实现 sobel 算子 */ external fun sobel(src: ByteArray):IntArray /** * 实现 laplace 算子 */ external fun laplace(src: ByteArray):IntArray /** * 实现 canny 算子 */ external fun canny(src: ByteArray, threshold1:Double, threshold2: Double, apertureSize:Int):IntArray /** * 实现 LoG 算子 */ external fun log(src: ByteArray):IntArray /** * 实现 DoG 算子 */ external fun dog(src: ByteArray, sigma1:Double, sigma2:Double, size:Int):IntArray /** * 轮廓分析 */ external fun contourAnalysis(src: ByteArray, binary: ByteArray, scalar:IntArray, contourFilterSettings: ContourFilterSettings, contourDisplaySettings: ContourDisplaySettings):IntArray /** * 实现高斯滤波 */ external fun gaussianBlur(src: ByteArray, ksize:Int, sigmaX: Double = 0.0, sigmaY: Double = 0.0):IntArray /** * 实现中值滤波 */ external fun medianBlur(src: ByteArray, ksize:Int):IntArray /** * 实现高斯双边滤波 */ external fun bilateralFilter(src: ByteArray, d:Int, sigmaColor:Double, sigmaSpace:Double):IntArray /** * 实现均值迁移滤波 */ external fun pyrMeanShiftFiltering(src: ByteArray, sp: Double, sr: Double):IntArray /** * 形态学操作 */ external fun morphologyEx(src: ByteArray, morphologicalOperationSettings: MorphologicalOperationSettings):IntArray /** * 模版匹配 */ external fun matchTemplate(src: ByteArray, template: ByteArray, scalar:IntArray, matchTemplateSettings: MatchTemplateSettings):IntArray /** * 解码相机拍摄的图片(例如 cr2、cr3 格式的图像) 用于图像 */ external fun decodeRawToBufferForPreView(path: String): DecodedPreviewImage? /** * 解码相机拍摄的图片,并进行调色,调色完之后更新 PyramidImage 然后给 Kotlin 层返回 DecodedPreviewImage 对象。 */ external fun decodeRawAndColorCorrection(path: String, nativePtr:Long, colorCorrectionSettings: ColorCorrectionSettings, cppObjectPtr:Long): DecodedPreviewImage? /** * 针对 raw 文件、heic 文件 * 使用 PyramidImage 中的原图数据进行调色,调色完之后更新 PyramidImage 然后给 Kotlin 层返回 DecodedPreviewImage 对象。 * 所有的操作都在 C++ 层实现,确保速度。 */ external fun colorCorrectionWithPyramidImage(nativePtr:Long, colorCorrectionSettings: ColorCorrectionSettings, cppObjectPtr:Long): DecodedPreviewImage? /** * 解码大图(主要针对 jpg、png、webp 格式的大图),并返回 DecodedPreviewImage 对象 */ external fun decodeLargeImageToBufferForPreView(path: String): DecodedPreviewImage? /** * raw 文件、heic 文件,保存的时候通过 jni 获取原图的信息,然后进行保存。 * 该函数通过指针获取 Native 的 PyramidImage 对象,再返回原图的信息。 */ external fun getNativeImage(nativePtr:Long): NativeImage? /** * 删除 PyramidImage 对象 */ external fun deletePyramidImage(nativePtr:Long): Long /** * 解码 heif 格式的图像 */ external fun decodeHeif(path: String): DecodedPreviewImage? } ================================================ FILE: resources/common/filterConfig.json ================================================ [ { "name": "AverageFilter", "desc": "均值模糊滤镜 - 通过平均邻域像素值实现平滑效果,有效减少图像噪声", "remark": "适用于图像降噪和预处理,效果温和自然", "params": [] }, { "name": "BilateralFilter", "desc": "双边滤波滤镜 - 在保持边缘清晰的同时平滑图像,智能降噪不损失细节", "remark": "ds: 空间距离参数,rs: 颜色相似度参数。值越大效果越强", "params": [ { "key": "ds", "type": "Double", "value": 1.0 }, { "key": "rs", "type": "Double", "value": 1.0 } ] }, { "name": "BlockFilter", "desc": "像素化滤镜 - 将图像分割成规则方块,创造复古像素艺术效果", "remark": "blockSize: 方块大小,值越大像素化效果越明显", "params": [ { "key": "blockSize", "type": "Int", "value": 2 } ] }, { "name": "BoxBlurFilter", "desc": "盒式模糊滤镜 - 使用矩形核进行快速模糊处理,适合大面积平滑", "remark": "hRadius: 水平模糊半径,vRadius: 垂直模糊半径,iterations: 迭代次数", "params": [ { "key": "hRadius", "type": "Int", "value": 5 }, { "key": "vRadius", "type": "Int", "value": 5 }, { "key": "iterations", "type": "Int", "value": 1 } ] }, { "name": "BumpFilter", "desc": "凹凸浮雕滤镜 - 通过边缘检测创造立体凹凸效果,增强纹理质感", "remark": "适合为平面图像添加立体感和深度", "params": [] }, { "name": "CarveFilter", "desc": "雕刻滤镜 - 模拟雕刻工艺效果,创造凹陷的立体视觉", "remark": "与浮雕效果相反,产生向内凹陷的视觉效果", "params": [] }, { "name": "ColorFilter", "desc": "色彩映射滤镜 - 应用预定义色彩方案,快速改变图像整体色调", "remark": "支持12种经典色彩风格:0:秋色,1:骨骼,2:冷色,3:暖色,4:HSV,5:喷射,6:海洋,7:粉色,8:彩虹,9:春天,10:夏天,11:冬天", "params": [ { "key": "style", "type": "Int", "value": 0 } ] }, { "name": "ColorHalftoneFilter", "desc": "彩色半调滤镜 - 模拟传统印刷网点效果,创造复古印刷风格", "remark": "dotRadius: 网点半径,控制半调效果的精细程度", "params": [ { "key": "dotRadius", "type": "Float", "value":2.0 } ] }, { "name": "ConBriFilter", "desc": "对比度亮度调节滤镜 - 精确控制图像明暗对比,提升视觉效果", "remark": "brightness: 亮度调节(1.0为原始亮度),contrast: 对比度调节(1.0为原始对比度)", "params": [ { "key": "brightness", "type": "Float", "value": 1.0 }, { "key": "contrast", "type": "Float", "value": 1.5 } ] }, { "name": "CrystallizeFilter", "desc": "水晶化滤镜 - 将图像分解为不规则水晶块,创造抽象艺术效果", "remark": "支持5种网格类型:0:随机,1:方形,2:六边形,3:八边形,4:三角形。scale控制水晶块大小", "params": [ { "key": "edgeThickness", "type": "Float", "value": 0.4 }, { "key": "scale", "type": "Float", "value": 16 }, { "key": "randomness", "type": "Float", "value": 0.0 }, { "key": "gridType", "type": "Int", "value": 2 } ] }, { "name": "CropFilter", "desc": "裁剪滤镜 - 提取图像指定区域,用于局部处理或构图调整", "remark": "x,y: 起始坐标,w,h: 裁剪区域宽高", "params": [ { "key": "x", "type": "Int", "value": 0 }, { "key": "y", "type": "Int", "value": 0 }, { "key": "w", "type": "Int", "value": 32 }, { "key": "h", "type": "Int", "value": 32 } ] }, { "name": "DiffuseFilter", "desc": "扩散滤镜 - 模拟光线散射效果,创造柔和朦胧的视觉氛围", "remark": "scale: 扩散强度,值越大效果越明显", "params": [ { "key": "scale", "type": "Float", "value": 4.0 } ] }, { "name": "EmbossFilter", "desc": "浮雕滤镜 - 通过边缘高光创造立体浮雕效果,增强图像层次感", "remark": "colorConstant: 浮雕强度,控制立体效果的明显程度", "params": [ { "key": "colorConstant", "type": "Int", "value": 100 } ] }, { "name": "EqualizeFilter", "desc": "直方图均衡化滤镜 - 自动调整图像亮度分布,改善整体视觉效果", "remark": "适用于曝光不足或对比度较低的图像,能显著提升图像质量", "params": [] }, { "name": "ExposureFilter", "desc": "曝光调节滤镜 - 模拟相机曝光控制,调整图像整体明暗", "remark": "exposure > 0,值越大图像越亮,适合修正曝光问题", "params": [ { "key": "exposure", "type": "Float", "value": 1 } ] }, { "name": "FastBlur2D", "desc": "快速二维模糊滤镜 - 高效的模糊算法,适合实时处理", "remark": "ksize: 模糊核大小,值越大模糊效果越强", "params": [ { "key": "ksize", "type": "Int", "value": 5 } ] }, { "name": "GainFilter", "desc": "增益偏置滤镜 - 精确控制图像亮度和对比度,专业级调色工具", "remark": "gain: 增益系数(0-1),bias: 偏置值(0-1),用于精细的亮度调节", "params": [ { "key": "gain", "type": "Float", "value": 0.5 }, { "key": "bias", "type": "Float", "value": 0.5 } ] }, { "name": "GammaFilter", "desc": "伽马校正滤镜 - 调整图像中间调亮度,改善显示效果", "remark": "gamma < 1图像变亮,gamma > 1图像变暗,常用于显示设备校正", "params": [ { "key": "gamma", "type": "Double", "value": 0.5 } ] }, { "name": "GaussianFilter", "desc": "高斯模糊滤镜 - 经典的高斯分布模糊,效果自然平滑", "remark": "radius: 模糊半径,基于高斯分布的最优模糊算法", "params": [ { "key": "radius", "type": "Float", "value": 5.0 } ] }, { "name": "GaussianNoiseFilter", "desc": "高斯噪声滤镜 - 添加随机噪声,模拟胶片颗粒或数字噪点", "remark": "sigma: 噪声强度,用于创造复古胶片效果或艺术化处理", "params": [ { "key": "sigma", "type": "Int", "value": 25 } ] }, { "name": "GradientFilter", "desc": "梯度滤镜 - 使用Sobel算子检测边缘,突出图像轮廓", "remark": "常用于边缘检测和图像分析,创造素描般的线条效果", "params": [] }, { "name": "GrayFilter", "desc": "灰度滤镜 - 将彩色图像转换为灰度,保留亮度信息", "remark": "经典的黑白转换,适合创造怀旧或艺术效果", "params": [] }, { "name": "HighPassFilter", "desc": "高通滤波滤镜 - 突出图像高频细节,创造发光边缘效果", "remark": "radius: 滤波半径,用于增强图像细节和创造特殊视觉效果", "params": [ { "key": "radius", "type": "Float", "value": 10.0 } ] }, { "name": "HSBAdjustFilter", "desc": "HSB色彩调节滤镜 - 分别调节色相、饱和度、亮度", "remark": "hFactor: 色相调节,sFactor: 饱和度调节,bFactor: 亮度调节", "params": [ { "key": "hFactor", "type": "Float", "value": 0.0 }, { "key": "sFactor", "type": "Float", "value": 0.0 }, { "key": "bFactor", "type": "Float", "value": 0.0 } ] }, { "name": "InvertFilter", "desc": "反色滤镜 - 反转图像所有颜色,创造负片效果", "remark": "经典的反色处理,常用于创造戏剧性视觉效果", "params": [] }, { "name": "LaplaceSharpenFilter", "desc": "拉普拉斯锐化滤镜 - 使用拉普拉斯算子增强图像边缘和细节", "remark": "专业的锐化算法,能有效提升图像清晰度", "params": [] }, { "name": "LensBlurFilter", "desc": "镜头模糊滤镜 - 模拟真实镜头景深效果,创造专业摄影质感", "remark": "radius: 模糊半径,bloom: 光晕强度,sides: 光圈边数(模拟不同镜头)", "params": [ { "key": "radius", "type": "Float", "value": 10.0 }, { "key": "bloom", "type": "Float", "value": 2.0 }, { "key": "bloomThreshold", "type": "Float", "value": 255.0 }, { "key": "angle", "type": "Float", "value": 0.0 }, { "key": "sides", "type": "Int", "value": 5 } ] }, { "name": "MarbleFilter", "desc": "大理石纹理滤镜 - 模拟天然大理石纹理,创造优雅的材质效果", "remark": "xScale/yScale: 纹理缩放,turbulence: 湍流强度,控制纹理复杂度", "params": [ { "key": "xScale", "type": "Float", "value": 4.0 }, { "key": "yScale", "type": "Float", "value": 4.0 }, { "key": "turbulence", "type": "Float", "value": 1.0 } ] }, { "name": "MaximumFilter", "desc": "最大值滤波滤镜 - 用邻域最大值替换像素,创造膨胀效果", "remark": "形态学处理,用于图像分析和特殊效果处理", "params": [] }, { "name": "MinimumFilter", "desc": "最小值滤波滤镜 - 用邻域最小值替换像素,创造腐蚀效果", "remark": "形态学处理,与最大值滤波配合使用", "params": [] }, { "name": "MirrorFilter", "desc": "镜像滤镜 - 创建图像镜像效果,适合对称构图", "remark": "opacity: 镜像透明度,centreY: 镜像中心,gap: 镜像间隙", "params": [ { "key": "opacity", "type": "Float", "value": 1.0 }, { "key": "centreY", "type": "Float", "value": 0.5 }, { "key": "gap", "type": "Float", "value": 0.0 } ] }, { "name": "MosaicFilter", "desc": "马赛克滤镜 - 将图像分割为规则色块,创造抽象艺术效果", "remark": "r: 马赛克块大小,值越大马赛克效果越明显", "params": [ { "key": "r", "type": "Int", "value": 3 } ] }, { "name": "MotionFilter", "desc": "运动模糊滤镜 - 模拟物体运动轨迹,创造动态视觉效果", "remark": "angle: 运动方向,distance: 模糊距离,zoom: 缩放效果", "params": [ { "key": "angle", "type": "Float", "value": 0.0 }, { "key": "distance", "type": "Float", "value": 0.0 }, { "key": "zoom", "type": "Float", "value": 0.4 } ] }, { "name": "NatureFilter", "desc": "自然风格滤镜 - 模拟自然现象的色彩效果,创造独特的艺术风格", "remark": "支持8种自然风格:1:大气,2:燃烧,3:雾霾,4:冰冻,5:熔岩,6:金属,7:海洋,8:水流", "params": [ { "key": "style", "type": "Int", "value": 1 } ] }, { "name": "OffsetFilter", "desc": "偏移滤镜 - 移动图像位置,用于特殊构图或效果组合", "remark": "xOffset: 水平偏移,yOffset: 垂直偏移,支持负值", "params": [ { "key": "xOffset", "type": "Int", "value": 0 }, { "key": "yOffset", "type": "Int", "value": 0 } ] }, { "name": "OilPaintFilter", "desc": "油画滤镜 - 模拟油画笔触效果,创造艺术绘画质感", "remark": "intensity: 油画强度,ksize: 笔触大小,控制油画效果的细腻程度", "params": [ { "key": "intensity", "type": "Int", "value": 40 }, { "key": "ksize", "type": "Int", "value": 10 } ] }, { "name": "PointillizeFilter", "desc": "点彩滤镜 - 将图像转换为彩色点阵,模拟点彩画派艺术风格", "remark": "支持5种点阵类型:0:随机,1:方形,2:六边形,3:八边形,4:三角形。scale控制点的大小", "params": [ { "key": "edgeThickness", "type": "Float", "value": 0.4 }, { "key": "fuzziness", "type": "Float", "value": 0.1 }, { "key": "scale", "type": "Float", "value": 16 }, { "key": "randomness", "type": "Float", "value": 0.0 }, { "key": "gridType", "type": "Int", "value": 2 } ] }, { "name": "PosterizeFilter", "desc": "色调分离滤镜 - 减少颜色层次,创造海报化的艺术效果", "remark": "numLevels: 颜色层次数,值越小效果越明显,创造强烈的视觉冲击", "params": [ { "key": "numLevels", "type": "Int", "value": 6 } ] }, { "name": "RippleFilter", "desc": "波纹滤镜 - 创造水面波纹效果,模拟液体表面的波动", "remark": "支持4种波形:0:正弦波,1:锯齿波,2:三角波,3:噪声波。xAmplitude/yAmplitude控制波纹幅度", "params": [ { "key": "xAmplitude", "type": "Float", "value": 5.0 }, { "key": "yAmplitude", "type": "Float", "value": 0.0 }, { "key": "xWavelength", "type": "Float", "value": 16.0 }, { "key": "yWavelength", "type": "Float", "value": 16.0 }, { "key": "waveType", "type": "Int", "value": 0 } ] }, { "name": "SepiaToneFilter", "desc": "棕褐色滤镜 - 创造怀旧老照片效果,营造复古氛围", "remark": "经典的复古色调,常用于创造历史感和怀旧情绪", "params": [] }, { "name": "SharpenFilter", "desc": "锐化滤镜 - 增强图像边缘和细节,提升图像清晰度", "remark": "基础的锐化处理,适用于轻微模糊的图像", "params": [] }, { "name": "SmearFilter", "desc": "涂抹滤镜 - 模拟绘画涂抹效果,创造艺术化的笔触质感", "remark": "支持4种涂抹形状:0:十字,1:线条,2:圆形,3:方形。density控制涂抹密度", "params": [ { "key": "angle", "type": "Float", "value": 0.0 }, { "key": "density", "type": "Float", "value": 0.5 }, { "key": "distance", "type": "Int", "value": 8 }, { "key": "shape", "type": "Int", "value": 1 }, { "key": "mix", "type": "Float", "value": 0.5 } ] }, { "name": "SolarizeFilter", "desc": "日晒滤镜 - 模拟过度曝光效果,创造高对比度的艺术效果", "remark": "经典的摄影特效,创造戏剧性的视觉冲击", "params": [] }, { "name": "SpotlightFilter", "desc": "聚光灯滤镜 - 模拟聚光灯照射效果,创造局部高亮", "remark": "factor: 聚光强度,用于突出图像特定区域", "params": [ { "key": "factor", "type": "Int", "value": 1 } ] }, { "name": "StrokeAreaFilter", "desc": "描边滤镜 - 提取图像边缘轮廓,创造铅笔画效果", "remark": "ksize: 描边粗细,控制线条的粗细程度", "params": [ { "key": "ksize", "type": "Double", "value": 10.0 } ] }, { "name": "SwimFilter", "desc": "水下滤镜 - 模拟水下视觉效果,创造扭曲的液体环境", "remark": "scale: 扭曲强度,amount: 效果强度,turbulence: 湍流程度", "params": [ { "key": "scale", "type": "Float", "value": 32.0 }, { "key": "stretch", "type": "Float", "value": 1.0 }, { "key": "angle", "type": "Float", "value": 0.0 }, { "key": "amount", "type": "Float", "value": 1.0 }, { "key": "turbulence", "type": "Float", "value": 1.0 }, { "key": "time", "type": "Float", "value": 0.0 } ] }, { "name": "USMFilter", "desc": "USM锐化滤镜 - 专业级锐化算法,在保持自然度的同时增强细节", "remark": "radius: 锐化半径,amount: 锐化强度,threshold: 锐化阈值。专业摄影师常用", "params": [ { "key": "radius", "type": "Float", "value": 2.0 }, { "key": "amount", "type": "Float", "value": 0.5 }, { "key": "threshold", "type": "Int", "value": 1 } ] }, { "name": "VariableBlurFilter", "desc": "可变模糊滤镜 - 使用不同模糊半径,创造渐变模糊效果", "remark": "hRadius: 水平模糊半径,vRadius: 垂直模糊半径,iterations: 迭代次数", "params": [ { "key": "hRadius", "type": "Int", "value": 5 }, { "key": "vRadius", "type": "Int", "value": 5 }, { "key": "iterations", "type": "Int", "value": 1 } ] }, { "name": "VignetteFilter", "desc": "暗角滤镜 - 创造边缘渐暗效果,突出中心主体,增强视觉焦点", "remark": "fade: 暗角强度,vignetteWidth: 暗角宽度,经典的照片后期处理效果", "params": [ { "key": "fade", "type": "Int", "value": 35 }, { "key": "vignetteWidth", "type": "Int", "value": 50 } ] }, { "name": "WaterFilter", "desc": "水波纹滤镜 - 创造逼真的水面波纹效果,模拟液体波动", "remark": "wavelength: 波长,amplitude: 振幅,centreX/centreY: 波纹中心,radius: 影响半径", "params": [ { "key": "wavelength", "type": "Float", "value": 16.0 }, { "key": "amplitude", "type": "Float", "value": 10.0 }, { "key": "phase", "type": "Float", "value": 0.0 }, { "key": "centreX", "type": "Float", "value": 0.5 }, { "key": "centreY", "type": "Float", "value": 0.5 }, { "key": "radius", "type": "Float", "value": 50.0 } ] }, { "name": "WhiteImageFilter", "desc": "增白滤镜 - 提升图像整体亮度,创造清新明亮的视觉效果", "remark": "beta: 增白系数,值越大图像越亮,适合修正曝光不足", "params": [ { "key": "beta", "type": "Double", "value": 1.1 } ] } ] ================================================ FILE: resources/package.json ================================================ { "name": "monica-web-screenshot", "version": "1.0.0", "description": "Web screenshot service for Monica", "main": "web-screenshot.js", "scripts": { "screenshot": "node web-screenshot.js" }, "dependencies": { "playwright": "^1.40.0" } } ================================================ FILE: resources/web-screenshot.js ================================================ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); // 解析命令行参数 const args = process.argv.slice(2); const url = args[0]; const outputPath = args[1]; if (!url || !outputPath) { console.error('用法: node web-screenshot.js [options]'); process.exit(1); } // 解析选项 const options = { fullPage: true, waitUntil: 'networkidle', timeout: 30000, viewportWidth: null, viewportHeight: null, deviceScaleFactor: 2, cookiesFile: null }; args.slice(2).forEach(arg => { if (arg.startsWith('--fullPage=')) { options.fullPage = arg.split('=')[1] === 'true'; } else if (arg.startsWith('--waitUntil=')) { options.waitUntil = arg.split('=')[1]; } else if (arg.startsWith('--timeout=')) { options.timeout = parseInt(arg.split('=')[1]); } else if (arg.startsWith('--viewportWidth=')) { options.viewportWidth = parseInt(arg.split('=')[1]); } else if (arg.startsWith('--viewportHeight=')) { options.viewportHeight = parseInt(arg.split('=')[1]); } else if (arg.startsWith('--deviceScaleFactor=')) { options.deviceScaleFactor = parseFloat(arg.split('=')[1]); } else if (arg.startsWith('--cookiesFile=')) { options.cookiesFile = arg.split('=')[1]; } }); async function autoScrollPage(page, timeout) { await page.evaluate(async maxDuration => { const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); const startTime = Date.now(); let lastHeight = -1; let stableRounds = 0; while (Date.now() - startTime < maxDuration) { const viewportHeight = window.innerHeight || 900; const currentHeight = Math.max( document.body.scrollHeight, document.documentElement.scrollHeight ); window.scrollBy(0, Math.max(400, Math.floor(viewportHeight * 0.85))); await delay(250); const nextHeight = Math.max( document.body.scrollHeight, document.documentElement.scrollHeight ); if (nextHeight === lastHeight && window.innerHeight + window.scrollY >= nextHeight - 4) { stableRounds += 1; } else { stableRounds = 0; } lastHeight = nextHeight; if (stableRounds >= 3) { break; } } window.scrollTo(0, 0); await delay(200); }, Math.min(timeout, 15000)); } async function forceLazyImages(page) { await page.evaluate(() => { const selectors = ['img', 'source', '[data-src]', '[data-original]', '[data-lazy-src]']; document.querySelectorAll(selectors.join(',')).forEach(node => { if (node.tagName === 'IMG') { node.loading = 'eager'; node.decoding = 'sync'; } const dataSrc = node.getAttribute('data-src'); const dataOriginal = node.getAttribute('data-original'); const dataLazySrc = node.getAttribute('data-lazy-src'); if (node.tagName === 'IMG' && !node.getAttribute('src')) { const fallbackSrc = dataSrc || dataOriginal || dataLazySrc; if (fallbackSrc) { node.setAttribute('src', fallbackSrc); } } if (node.tagName === 'SOURCE' && !node.getAttribute('srcset')) { const fallbackSrcSet = dataSrc || dataOriginal || dataLazySrc; if (fallbackSrcSet) { node.setAttribute('srcset', fallbackSrcSet); } } }); }); } async function waitForImages(page, timeout) { await page.evaluate(async maxDuration => { const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); const startTime = Date.now(); while (Date.now() - startTime < maxDuration) { const pendingImages = Array.from(document.images).filter(img => { const style = window.getComputedStyle(img); const visible = style.display !== 'none' && style.visibility !== 'hidden'; const hasSource = Boolean(img.currentSrc || img.src); return visible && hasSource && !img.complete; }); if (pendingImages.length === 0) { await delay(300); const recheckPending = Array.from(document.images).filter(img => { const style = window.getComputedStyle(img); const visible = style.display !== 'none' && style.visibility !== 'hidden'; const hasSource = Boolean(img.currentSrc || img.src); return visible && hasSource && !img.complete; }); if (recheckPending.length === 0) { break; } } await delay(250); } }, Math.min(timeout, 10000)); } async function settleLongPage(page, timeout) { await forceLazyImages(page); await autoScrollPage(page, timeout); await forceLazyImages(page); await waitForImages(page, timeout); } async function captureScreenshot() { let browser = null; try { console.log(`开始截图: ${url}`); browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ deviceScaleFactor: Number.isFinite(options.deviceScaleFactor) && options.deviceScaleFactor > 0 ? options.deviceScaleFactor : 2 }); // 设置视口 if (options.viewportWidth && options.viewportHeight) { await page.setViewportSize({ width: options.viewportWidth, height: options.viewportHeight }); } // 加载 Cookie(如果提供) if (options.cookiesFile && fs.existsSync(options.cookiesFile)) { try { const cookiesJson = fs.readFileSync(options.cookiesFile, 'utf8'); const cookies = JSON.parse(cookiesJson); if (Array.isArray(cookies) && cookies.length > 0) { // 提取域名(从 URL) const urlObj = new URL(url); const domain = urlObj.hostname; // 设置 Cookie await page.context().addCookies(cookies.map(cookie => ({ name: cookie.name, value: cookie.value, domain: cookie.domain || domain, path: cookie.path || '/', expires: cookie.expires || Math.floor(Date.now() / 1000) + 86400, // 默认1天后过期 httpOnly: cookie.httpOnly || false, secure: cookie.secure || false, sameSite: cookie.sameSite || 'Lax' }))); console.log(`已加载 ${cookies.length} 个 Cookie`); } } catch (error) { throw new Error(`加载 Cookie 失败: ${error.message}`); } } // 导航到页面 await page.goto(url, { waitUntil: options.waitUntil, timeout: options.timeout }); // 等待页面完全加载(额外等待动态内容) await page.waitForTimeout(1000); if (options.fullPage) { console.log('开始预加载长页内容'); await settleLongPage(page, options.timeout); } else { await forceLazyImages(page); await waitForImages(page, options.timeout); } // 截图 console.log(`截图选项: fullPage=${options.fullPage}, waitUntil=${options.waitUntil}`); await page.screenshot({ path: outputPath, fullPage: options.fullPage, type: 'png' }); console.log(`截图成功保存到: ${outputPath}`); return 0; } catch (error) { console.error(`截图失败: ${error.message}`); console.error(error.stack); return 1; } finally { if (browser) { await browser.close(); } } } captureScreenshot() .then(code => { process.exitCode = code; }) .catch(error => { console.error(`截图异常: ${error.message}`); console.error(error.stack); process.exitCode = 1; }); ================================================ FILE: settings.gradle.kts ================================================ pluginManagement { repositories { google() gradlePluginPortal() mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") maven( "https://jitpack.io" ) } plugins { kotlin("multiplatform").version(extra["kotlin.version"] as String) id("org.jetbrains.compose").version(extra["compose.version"] as String) } } plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } rootProject.name = "Monica" include("domain") include("opencv") include("config") include("imageprocess") include("i18n") ================================================ FILE: src/jvmMain/kotlin/Main.kt ================================================ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.res.painterResource import androidx.compose.ui.window.* import cn.netdiscovery.monica.config.* import cn.netdiscovery.monica.config.category.ConfigDefinitions import cn.netdiscovery.monica.config.storage.ConfigManager import cn.netdiscovery.monica.rxcache.rxCache import cn.netdiscovery.monica.di.viewModelModule import cn.netdiscovery.monica.history.EditHistoryCenter import cn.netdiscovery.monica.http.healthCheck import cn.netdiscovery.monica.i18n.LocalizationManager import cn.netdiscovery.monica.rxcache.getFilterNames import cn.netdiscovery.monica.rxcache.initFilterMap import cn.netdiscovery.monica.rxcache.initFilterParamsConfig import cn.netdiscovery.monica.state.* import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.experiment import cn.netdiscovery.monica.ui.controlpanel.ai.faceswap.faceSwap import cn.netdiscovery.monica.ui.controlpanel.cartoon.cartoon import cn.netdiscovery.monica.ui.controlpanel.colorcorrection.colorCorrection import cn.netdiscovery.monica.ui.controlpanel.colorpick.colorPick import cn.netdiscovery.monica.ui.controlpanel.cropimage.cropImage import cn.netdiscovery.monica.ui.controlpanel.doodle.drawImage import cn.netdiscovery.monica.ui.controlpanel.filter.filter import cn.netdiscovery.monica.ui.controlpanel.generategif.generateGif import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.shapeDrawing import cn.netdiscovery.monica.ui.controlpanel.compression.compressionView import cn.netdiscovery.monica.ui.controlpanel.webscreenshot.webScreenshot import cn.netdiscovery.monica.ui.main.generalSettings import cn.netdiscovery.monica.ui.main.mainView import cn.netdiscovery.monica.ui.main.openURLDialog import cn.netdiscovery.monica.ui.theme.CustomMaterialTheme import cn.netdiscovery.monica.ui.main.showVersionInfo import cn.netdiscovery.monica.ui.preview.PreviewViewModel import cn.netdiscovery.monica.ui.showimage.showImage import cn.netdiscovery.monica.ui.widget.PageLifecycle import cn.netdiscovery.monica.ui.widget.showLoading import cn.netdiscovery.monica.exception.ErrorHandler import cn.netdiscovery.monica.exception.ErrorState import cn.netdiscovery.monica.ui.widget.topToast import cn.netdiscovery.monica.utils.chooseImage import cn.netdiscovery.monica.utils.getBufferedImage import cn.netdiscovery.monica.utils.captureFullScreen import cn.netdiscovery.monica.utils.loadScreenshotToState import cn.netdiscovery.monica.utils.getUrlFromClipboard import cn.netdiscovery.monica.utils.loadWebScreenshotToState import cn.netdiscovery.monica.ui.screenshot.showSwingScreenshotAreaSelector import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.compose.KoinApplication import org.koin.compose.koinInject import org.koin.core.Koin import org.slf4j.Logger import org.slf4j.LoggerFactory val filterNames = mutableListOf() val filterMaps = mutableMapOf() var loadingDisplay by mutableStateOf(false) var openURLDialog by mutableStateOf(false) var picUrl by mutableStateOf("") var showVersion by mutableStateOf(false) private var showTopToast by mutableStateOf(false) private var topToastMessage by mutableStateOf("") var showGeneralSettings by mutableStateOf(false) var showScreenshotAreaSelector by mutableStateOf(false) lateinit var mAppKoin: Koin private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) fun main() = application { // 初始化配置管理器(必须在 ApplicationState 创建之前) ConfigManager.initialize(rxCache) ConfigDefinitions.initialize() val trayState = rememberTrayState() val applicationState = rememberApplicationState( rememberCoroutineScope(), trayState ) // 全局错误处理状态 val errorState = remember { ErrorState() } PageLifecycle( onInit = { logger.info("首页启动时初始化") initData(applicationState) }, onDisposeEffect = { logger.info("首页关闭") applicationState.clearImage() EditHistoryCenter.clearAll() logger.info("释放全部资源") } ) lateinit var previewViewModel: PreviewViewModel Tray( state = trayState, icon = painterResource("images/launcher.ico"), menu = { Item( text = LocalizationManager.getString("software_version_info"), onClick = { showVersion = true }, ) Item( text = LocalizationManager.getString("open_local_image"), onClick = { chooseImage(applicationState) { file -> val image = getBufferedImage(file, applicationState) applicationState.rawImage = image applicationState.currentImage = applicationState.rawImage applicationState.rawImageFile = file } }, ) Item( text = LocalizationManager.getString("load_network_image"), onClick = { openURLDialog = true }, ) Separator() Item( text = LocalizationManager.getString("screenshot_full_screen"), onClick = { // 全屏截图:先隐藏主窗口,延迟截图,再恢复主窗口 applicationState.window.isVisible = false applicationState.scope.launch { try { delay(200) // 等待窗口隐藏动画完成 val screenshot = captureFullScreen() delay(100) // 在 AWT Event Dispatch Thread 中恢复窗口可见性 java.awt.EventQueue.invokeLater { applicationState.window.isVisible = true if (screenshot != null) { loadScreenshotToState(applicationState, screenshot) } } } catch (e: Exception) { logger.error("全屏截图失败", e) // 确保窗口在错误时也能恢复 java.awt.EventQueue.invokeLater { applicationState.window.isVisible = true } } } }, ) Item( text = LocalizationManager.getString("screenshot_area"), onClick = { // 区域选择:显示简化对话框,用户点击截图 showScreenshotAreaSelector = true }, ) Item( text = LocalizationManager.getString("web_screenshot"), onClick = { // 网页长截图:从剪贴板读取 URL 并直接截图 applicationState.scope.launch { try { val url = getUrlFromClipboard() if (url == null) { showTopToast("剪贴板中没有有效的 URL,请先复制网页地址") return@launch } loadWebScreenshotToState(applicationState, url) } catch (e: Exception) { logger.error("网页截图失败", e) showTopToast("网页截图失败: ${e.message}") } } }, ) Separator() Item( text = LocalizationManager.getString("save_image"), onClick = { previewViewModel.saveImage(applicationState) }, ) } ) Thread.setDefaultUncaughtExceptionHandler{ _, throwable -> logger.error("全局异常捕获", throwable) } Window(onCloseRequest = ::exitApplication, title = "${LocalizationManager.getString("monica_image_editor")} $appVersion", state = rememberWindowState(width = width, height = height).apply { position = WindowPosition(Alignment.BottomCenter) }) { KoinApplication(application = { mAppKoin = koin modules(viewModelModule) }) { previewViewModel = koinInject() applicationState.window = window CustomMaterialTheme(theme = applicationState.getCurrentThemeValue().also { logger.info("主窗口使用主题: ${it.name}") }) { mainView(applicationState) // 全局错误处理 - 在 mainView 之后,确保在最顶层 ErrorHandler(errorState) if (loadingDisplay) { showLoading() } if (openURLDialog) { openURLDialog( onConfirm = { openURLDialog = false previewViewModel.loadUrl(picUrl, applicationState) picUrl = "" }, onDismiss = { openURLDialog = false }) } if (showTopToast) { topToast(message = topToastMessage) { showTopToast = false } } if (showVersion) { showVersionInfo { showVersion = false } } if (showGeneralSettings) { generalSettings(applicationState) { showGeneralSettings = false } } } } } if (applicationState.isShowPreviewWindow) { if (applicationState.currentImage == null && (applicationState.currentStatus != GenerateGifStatus && applicationState.currentStatus != CompressionStatus && applicationState.currentStatus != FilterStatus && applicationState.currentStatus != FaceSwapStatus && applicationState.currentStatus != OpenCVDebugStatus && applicationState.currentStatus != CartoonStatus && applicationState.currentStatus != WebScreenshotStatus)) { showTopToast("请先选择图像") return@application } Window( title = getWindowsTitle(applicationState), onCloseRequest = { when(applicationState.currentStatus) { DoodleStatus -> { showTopToast("想要保存涂鸦效果,需要点击保存按钮") } ShapeDrawingStatus -> { showTopToast("想要保存形状绘制的结果,需要点击保存按钮") } } applicationState.closePreviewWindow() }, state = rememberWindowState().apply { position = WindowPosition(Alignment.Center) placement = if(isWindows) WindowPlacement.Maximized else WindowPlacement.Fullscreen } ) { CustomMaterialTheme(theme = applicationState.getCurrentThemeValue().also { logger.info("预览窗口使用主题: ${it.name}") }) { when(applicationState.currentStatus) { ZoomPreviewStatus -> { logger.info("enter ShowImgView") showImage(applicationState) } ColorPickStatus -> { logger.info("enter ColorPickView") colorPick(applicationState) } GenerateGifStatus -> { logger.info("enter GenerateGifView") generateGif(applicationState) } DoodleStatus -> { logger.info("enter DoodleView") drawImage(applicationState) } ShapeDrawingStatus -> { logger.info("enter ShapeDrawingViewRefactored") shapeDrawing(applicationState) } CropSizeStatus -> { logger.info("enter CropImageView") cropImage(applicationState) } ColorCorrectionStatus -> { logger.info("enter ColorCorrectionView") colorCorrection(applicationState) } FilterStatus -> { logger.info("enter FilterView") filter(applicationState) } FaceSwapStatus -> { logger.info("enter FaceSwapView") faceSwap(applicationState) } OpenCVDebugStatus -> { logger.info("enter OpenCVDebugView") experiment(applicationState) } CartoonStatus -> { logger.info("enter CartoonView") cartoon(applicationState) } CompressionStatus -> { logger.info("enter CompressionView") compressionView(applicationState) } WebScreenshotStatus -> { logger.info("enter WebScreenshotView") webScreenshot(applicationState) } else -> {} } } } } if (showScreenshotAreaSelector) { // 使用 Swing 实现的区域选择器(在 macOS 上更可靠) showSwingScreenshotAreaSelector( state = applicationState, onDismiss = { showScreenshotAreaSelector = false } ) } } fun showTopToast(message:String) { topToastMessage = message showTopToast = true } /** * 初始化数据,只初始一次,包括: * 1. 初始化配置定义 * 2. 加载滤镜的配置 * 3. 加载 opencv 的图像处理库 * 4. 校验算法服务 */ private fun initData(state:ApplicationState) { logger.info("os = $os, arch = $arch, osVersion = $osVersion, javaVersion = $javaVersion, javaVendor = $javaVendor, monicaVersion = $appVersion, kotlinVersion = $kotlinVersion") // 配置管理器已在 main() 函数开始处初始化,这里不再重复初始化 filterNames.addAll(getFilterNames()) // 获取所有滤镜的名称 if (rxCache.allKeys.isEmpty()) { // 第一次加载会缓存所有滤镜的参数配置 initFilterParamsConfig() } if (filterMaps.isEmpty()) { initFilterMap() } logger.info("MonicaImageProcess Version = $imageProcessVersion, OpenCV Version = $openCVVersion") if (state.algorithmUrlText.isNotEmpty()) { val status = try { val baseUrl = state.algorithmUrlText if (healthCheck(baseUrl)) { STATUS_HTTP_SERVER_OK } else { STATUS_HTTP_SERVER_FAILED } } catch (e:Exception) { STATUS_HTTP_SERVER_FAILED } if (status == STATUS_HTTP_SERVER_OK) { logger.info("算法服务可用") } else { logger.info("算法服务不可用") } } } private fun getWindowsTitle(state: ApplicationState): String = when(state.currentStatus) { DoodleStatus -> LocalizationManager.getString("window_title_doodle") ShapeDrawingStatus -> LocalizationManager.getString("window_title_shape_drawing") ColorPickStatus -> LocalizationManager.getString("window_title_color_pick") GenerateGifStatus -> LocalizationManager.getString("window_title_generate_gif") CropSizeStatus -> LocalizationManager.getString("window_title_crop_size") ColorCorrectionStatus -> LocalizationManager.getString("window_title_color_correction") FilterStatus -> LocalizationManager.getString("window_title_filter") OpenCVDebugStatus -> LocalizationManager.getString("window_title_opencv_debug") FaceSwapStatus -> LocalizationManager.getString("window_title_face_swap") CartoonStatus -> LocalizationManager.getString("window_title_cartoon") CompressionStatus -> LocalizationManager.getString("window_title_compression") WebScreenshotStatus -> LocalizationManager.getString("window_title_web_screenshot") else -> LocalizationManager.getString("window_title_preview") } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/config/Constant.kt ================================================ package cn.netdiscovery.monica.config import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.opencv.ImageProcess /** * * @FileName: * cn.netdiscovery.monica.config.Constant * @author: Tony Shen * @date: 2024/5/7 10:55 * @version: V1.0 <描述当前版本功能> */ val imageProcessVersion by lazy { // 本地算法库的版本 ImageProcess.getVersion() } val openCVVersion by lazy { // OpenCV 的版本 ImageProcess.getOpenCVVersion() } val previewWidth = 750 val width = (previewWidth * 2.toFloat()).dp val height = 1000.dp val loadingWidth = (previewWidth*2*0.7).dp val titleTextSize = 32.sp val subTitleTextSize = 20.sp ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/di/appModule.kt ================================================ package cn.netdiscovery.monica.di import org.koin.core.module.dsl.singleOf import org.koin.dsl.module import cn.netdiscovery.monica.ui.controlpanel.ai.AIViewModel import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.BinaryImageViewModel import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.ContourAnalysisViewModel import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.EdgeDetectionViewModel import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.HistoryViewModel import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.ImageEnhanceViewModel import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.ImageDenoisingViewModel import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.MatchTemplateViewModel import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.MorphologicalOperationsViewModel import cn.netdiscovery.monica.ui.controlpanel.ai.faceswap.FaceSwapViewModel import cn.netdiscovery.monica.ui.controlpanel.cartoon.CartoonViewModel import cn.netdiscovery.monica.ui.controlpanel.colorcorrection.ColorCorrectionViewModel import cn.netdiscovery.monica.ui.controlpanel.cropimage.CropViewModel import cn.netdiscovery.monica.ui.controlpanel.doodle.DoodleViewModel import cn.netdiscovery.monica.ui.controlpanel.filter.viewmodel.FilterViewModel import cn.netdiscovery.monica.ui.controlpanel.generategif.GenerateGifViewModel import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.ShapeDrawingViewModel import cn.netdiscovery.monica.ui.controlpanel.webscreenshot.WebScreenshotViewModel import cn.netdiscovery.monica.ui.main.MainViewModel import cn.netdiscovery.monica.ui.preview.PreviewViewModel /** * * @FileName: * cn.netdiscovery.monica.di.appModule * @author: Tony Shen * @date: 2024/5/7 20:28 * @version: V1.0 <描述当前版本功能> */ val viewModelModule = module { singleOf(::MainViewModel) singleOf(::PreviewViewModel) singleOf(::DoodleViewModel) singleOf(::ShapeDrawingViewModel) singleOf(::CropViewModel) singleOf(::ColorCorrectionViewModel) singleOf(::FilterViewModel) singleOf(::AIViewModel) singleOf(::FaceSwapViewModel) singleOf(::BinaryImageViewModel) singleOf(::EdgeDetectionViewModel) singleOf(::ContourAnalysisViewModel) singleOf(::ImageEnhanceViewModel) singleOf(::ImageDenoisingViewModel) singleOf(::MorphologicalOperationsViewModel) singleOf(::MatchTemplateViewModel) singleOf(::HistoryViewModel) singleOf(::GenerateGifViewModel) singleOf(::CartoonViewModel) singleOf(::WebScreenshotViewModel) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/AppError.kt ================================================ package cn.netdiscovery.monica.exception /** * 应用错误类 * @FileName: * cn.netdiscovery.monica.exception.AppError * @author: Tony Shen * @date: 2025/9/26 17:20 * @version: V1.0 <描述当前版本功能> */ data class AppError( val type: ErrorType, val severity: ErrorSeverity, val message: String, // 技术性错误信息,用于日志 val userMessage: String, // 用户友好的错误信息 val cause: Throwable? = null, // 原始异常 val retryable: Boolean = false, // 是否可重试 val context: Map = emptyMap(), // 错误上下文信息 val timestamp: Long = System.currentTimeMillis(), // 错误发生时间 val errorCode: String? = null // 错误代码,用于国际化 ) { companion object { fun fromException( exception: Throwable, type: ErrorType, severity: ErrorSeverity = ErrorSeverity.MEDIUM, userMessage: String = "操作失败,请重试" ): AppError { return AppError( type = type, severity = severity, message = exception.message ?: "未知错误", userMessage = userMessage, cause = exception, retryable = isRetryableException(exception) ) } private fun isRetryableException(exception: Throwable): Boolean { return when (exception) { is java.net.SocketTimeoutException, is java.net.ConnectException, is java.io.IOException -> true else -> false } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/ErrorComposable.kt ================================================ package cn.netdiscovery.monica.exception import androidx.compose.material.AlertDialog import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.* import androidx.compose.ui.Modifier import cn.netdiscovery.monica.ui.widget.centerToast import cn.netdiscovery.monica.ui.widget.topToast /** * 错误处理Compose组件 * @FileName: * cn.netdiscovery.monica.exception.ErrorComposable * @author: Tony Shen * @date: 2025/9/28 10:40 * @version: V1.0 <描述当前版本功能> */ @Composable fun ErrorHandler( errorState: ErrorState ) { // 初始化错误管理器 val errorManager = remember { ErrorManager().apply { setErrorState(errorState) // 注册默认错误处理器 registerHandler(cn.netdiscovery.monica.exception.handlers.NetworkErrorHandler()) registerHandler(cn.netdiscovery.monica.exception.handlers.ImageProcessingErrorHandler()) registerHandler(cn.netdiscovery.monica.exception.handlers.ValidationErrorHandler()) registerHandler(cn.netdiscovery.monica.exception.handlers.FileIOErrorHandler()) registerHandler(cn.netdiscovery.monica.exception.handlers.AIServiceErrorHandler()) } } // 设置全局错误管理器 LaunchedEffect(errorManager) { GlobalErrorManager.setInstance(errorManager, errorState) } // 监听 Toast 消息 val toastMessage by errorState.toastMessage.collectAsState() // 显示 Toast if (toastMessage != null) { centerToast( modifier = Modifier, message = toastMessage!!, onDismissCallback = { errorState.clearToast() } ) } // 监听 Top Toast 消息 val topToastMessage by errorState.topToastMessage.collectAsState() // 显示 Top Toast if (topToastMessage != null) { topToast( modifier = Modifier, message = topToastMessage!!, onDismissCallback = { errorState.clearToast() } ) } // 监听对话框状态 val dialogState by errorState.dialogState.collectAsState() dialogState?.let { state -> AlertDialog( onDismissRequest = { errorState.clearDialog() state.onDismiss() }, title = { Text(state.title) }, text = { Text(state.message) }, confirmButton = { TextButton(onClick = { errorState.clearDialog() state.onDismiss() }) { Text("确定") } } ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/ErrorExtensions.kt ================================================ package cn.netdiscovery.monica.exception import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** * 错误处理扩展函数 * @FileName: * cn.netdiscovery.monica.exception.ErrorExtensions * @author: Tony Shen * @date: 2025/9/28 10:35 * @version: V1.0 <描述当前版本功能> */ /** * 全局错误处理函数 - 用于在非 Composable 上下文中调用 */ fun showError( type: ErrorType, severity: ErrorSeverity = ErrorSeverity.MEDIUM, message: String = "操作失败,请重试", userMessage: String = "操作失败,请重试" ) { val error = AppError( type = type, severity = severity, message = message, userMessage = userMessage ) val errorState = GlobalErrorManager.getErrorState() if (errorState != null) { errorState.showError(error) } else { // 如果 GlobalErrorManager 未初始化,直接打印日志 println("错误: ${error.userMessage}") } } /** * 安全执行IO操作 */ suspend fun safeExecuteIO( errorType: ErrorType = ErrorType.FILE_IO_ERROR, severity: ErrorSeverity = ErrorSeverity.MEDIUM, userMessage: String = "文件操作失败,请重试", retryable: Boolean = true, context: Map = emptyMap(), block: suspend () -> T ): Result { return withContext(Dispatchers.IO) { safeExecute(errorType, severity, userMessage, retryable, context, block) } } /** * 安全执行异步操作 */ suspend fun safeExecute( errorType: ErrorType, severity: ErrorSeverity = ErrorSeverity.MEDIUM, userMessage: String = "操作失败,请重试", retryable: Boolean = false, context: Map = emptyMap(), block: suspend () -> T ): Result { return try { Result.Success(block()) } catch (e: Exception) { val error = AppError( type = errorType, severity = severity, message = e.message ?: "未知错误", userMessage = userMessage, cause = e, retryable = retryable, context = context ) GlobalErrorManager.getErrorState()?.showError(error) ?: run { // 如果全局错误管理器未初始化,仅记录日志 println("错误: ${error.message}") } Result.Error(error) } } /** * 安全执行同步操作 */ fun safeExecuteSync( errorType: ErrorType, severity: ErrorSeverity = ErrorSeverity.MEDIUM, userMessage: String = "操作失败,请重试", retryable: Boolean = false, context: Map = emptyMap(), block: () -> T ): Result { return try { Result.Success(block()) } catch (e: Exception) { val error = AppError( type = errorType, severity = severity, message = e.message ?: "未知错误", userMessage = userMessage, cause = e, retryable = retryable, context = context ) GlobalErrorManager.getErrorState()?.showError(error) ?: run { // 如果全局错误管理器未初始化,仅记录日志 println("错误: ${error.message}") } Result.Error(error) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/ErrorHandler.kt ================================================ package cn.netdiscovery.monica.exception /** * 错误处理器接口 * @FileName: * cn.netdiscovery.monica.exception.ErrorHandler * @author: Tony Shen * @date: 2025/9/26 17:15 * @version: V1.0 <描述当前版本功能> */ interface ErrorHandler { fun handleError(error: AppError): ErrorHandlingStrategy fun canHandle(errorType: ErrorType): Boolean } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/ErrorManager.kt ================================================ package cn.netdiscovery.monica.exception import cn.netdiscovery.monica.utils.logger /** * 错误管理器 * @FileName: * cn.netdiscovery.monica.exception.ErrorManager * @author: Tony Shen * @date: 2025/9/26 17:32 * @version: V1.0 <描述当前版本功能> */ // 全局错误管理器实例 object GlobalErrorManager { private var _instance: ErrorManager? = null private var _errorState: ErrorState? = null fun setInstance(errorManager: ErrorManager, errorState: ErrorState) { _instance = errorManager _errorState = errorState } fun getInstance(): ErrorManager? = _instance fun getErrorState(): ErrorState? = _errorState } class ErrorManager { private val handlers = mutableListOf() private val logger = logger() private var errorState: ErrorState? = null fun setErrorState(errorState: ErrorState) { this.errorState = errorState } fun registerHandler(handler: ErrorHandler) { handlers.add(handler) } fun handleError(error: AppError) { // 记录错误日志 logError(error) // 查找合适的处理器 val handler = handlers.find { it.canHandle(error.type) } // 根据处理策略更新状态 when (handler?.handleError(error)) { ErrorHandlingStrategy.SHOW_TOAST -> { errorState?.showToast(error.userMessage) } ErrorHandlingStrategy.SHOW_DIALOG -> { errorState?.showDialog("错误", error.userMessage) } ErrorHandlingStrategy.RETRY -> { // 重试逻辑 } ErrorHandlingStrategy.LOG_ONLY -> { // 仅记录日志 } else -> logger.warn("未处理的错误: ${error.message}") } } private fun logError(error: AppError) { when (error.severity) { ErrorSeverity.LOW -> logger.debug("${error.type}: ${error.message}", error.cause) ErrorSeverity.MEDIUM -> logger.warn("${error.type}: ${error.message}", error.cause) ErrorSeverity.HIGH -> logger.error("${error.type}: ${error.message}", error.cause) ErrorSeverity.CRITICAL -> logger.error("严重错误 [${error.type}]: ${error.message}", error.cause) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/ErrorState.kt ================================================ package cn.netdiscovery.monica.exception import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** * 错误状态管理 * @FileName: * cn.netdiscovery.monica.exception.ErrorState * @author: Tony Shen * @date: 2025/9/28 10:17 * @version: V1.0 <描述当前版本功能> */ class ErrorState { private val _toastMessage = MutableStateFlow(null) val toastMessage: StateFlow = _toastMessage.asStateFlow() private val _topToastMessage = MutableStateFlow(null) val topToastMessage: StateFlow = _topToastMessage.asStateFlow() private val _dialogState = MutableStateFlow(null) val dialogState: StateFlow = _dialogState.asStateFlow() data class DialogState( val title: String, val message: String, val onDismiss: () -> Unit ) fun showToast(message: String) { _toastMessage.value = message } fun showTopToast(message: String) { _topToastMessage.value = message } fun showDialog(title: String, message: String, onDismiss: () -> Unit = {}) { _dialogState.value = DialogState(title, message, onDismiss) } fun clearToast() { _toastMessage.value = null _topToastMessage.value = null } fun clearDialog() { _dialogState.value = null } /** * 直接显示错误 - 用于在非 Composable 上下文中调用 */ fun showError(error: AppError) { when (error.severity) { ErrorSeverity.LOW -> { showTopToast(error.userMessage) } ErrorSeverity.MEDIUM -> { showToast(error.userMessage) } ErrorSeverity.HIGH -> { showDialog("错误", error.userMessage) } ErrorSeverity.CRITICAL -> { showDialog("严重错误", error.userMessage) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/Errors.kt ================================================ package cn.netdiscovery.monica.exception /** * * @FileName: * cn.netdiscovery.monica.exception.Errors * @author: Tony Shen * @date: 2025/9/26 17:12 * @version: V1.0 <描述当前版本功能> */ // 错误类型枚举 enum class ErrorType { NETWORK_ERROR, // 网络错误 IMAGE_PROCESSING, // 图像处理错误 VALIDATION_ERROR, // 验证错误 FILE_IO_ERROR, // 文件IO错误 CONFIG_ERROR, // 配置错误 AI_SERVICE_ERROR, // AI服务错误 UI_ERROR, // UI错误 UNKNOWN_ERROR // 未知错误 } // 错误严重程度 enum class ErrorSeverity { LOW, // 低严重程度,不影响主要功能 MEDIUM, // 中等严重程度,影响部分功能 HIGH, // 高严重程度,影响主要功能 CRITICAL // 严重错误,可能导致应用崩溃 } // 错误处理策略 enum class ErrorHandlingStrategy { SHOW_TOAST, // 显示Toast提示 SHOW_DIALOG, // 显示错误对话框 LOG_ONLY, // 仅记录日志 RETRY, // 自动重试 FALLBACK, // 降级处理 IGNORE // 忽略错误 } // 统一错误结果 sealed class Result { data class Success(val data: T) : Result() data class Error(val error: AppError) : Result() } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/MonicaException.kt ================================================ package cn.netdiscovery.monica.exception /** * * @FileName: * cn.netdiscovery.monica.exception.MonicaException * @author: Tony Shen * @date: 2025/6/9 11:25 * @version: V1.0 <描述当前版本功能> */ class MonicaException : RuntimeException { constructor() : super() constructor(message: String?, cause: Throwable?) : super(message, cause) constructor(message: String?) : super(message) constructor(cause: Throwable?) : super(cause) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/handlers/AIServiceErrorHandler.kt ================================================ package cn.netdiscovery.monica.exception.handlers import cn.netdiscovery.monica.exception.* /** * AI服务错误处理器 * @FileName: * cn.netdiscovery.monica.exception.handlers.AIServiceErrorHandler * @author: Tony Shen * @date: 2025/9/28 10:30 * @version: V1.0 <描述当前版本功能> */ class AIServiceErrorHandler : ErrorHandler { override fun handleError(error: AppError): ErrorHandlingStrategy { return when (error.severity) { ErrorSeverity.LOW -> ErrorHandlingStrategy.SHOW_TOAST ErrorSeverity.MEDIUM -> ErrorHandlingStrategy.SHOW_DIALOG ErrorSeverity.HIGH -> if (error.retryable) ErrorHandlingStrategy.RETRY else ErrorHandlingStrategy.SHOW_DIALOG ErrorSeverity.CRITICAL -> ErrorHandlingStrategy.FALLBACK } } override fun canHandle(errorType: ErrorType): Boolean = errorType == ErrorType.AI_SERVICE_ERROR } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/handlers/FileIOErrorHandler.kt ================================================ package cn.netdiscovery.monica.exception.handlers import cn.netdiscovery.monica.exception.* /** * 文件IO错误处理器 * @FileName: * cn.netdiscovery.monica.exception.handlers.FileIOErrorHandler * @author: Tony Shen * @date: 2025/9/28 10:30 * @version: V1.0 <描述当前版本功能> */ class FileIOErrorHandler : ErrorHandler { override fun handleError(error: AppError): ErrorHandlingStrategy { return when (error.severity) { ErrorSeverity.LOW -> ErrorHandlingStrategy.SHOW_TOAST ErrorSeverity.MEDIUM -> ErrorHandlingStrategy.SHOW_DIALOG ErrorSeverity.HIGH -> ErrorHandlingStrategy.SHOW_DIALOG ErrorSeverity.CRITICAL -> ErrorHandlingStrategy.FALLBACK } } override fun canHandle(errorType: ErrorType): Boolean = errorType == ErrorType.FILE_IO_ERROR } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/handlers/ImageProcessingErrorHandler.kt ================================================ package cn.netdiscovery.monica.exception.handlers import cn.netdiscovery.monica.exception.* /** * 图像处理错误处理器 * @FileName: * cn.netdiscovery.monica.exception.handlers.ImageProcessingErrorHandler * @author: Tony Shen * @date: 2025/9/28 10:30 * @version: V1.0 <描述当前版本功能> */ class ImageProcessingErrorHandler : ErrorHandler { override fun handleError(error: AppError): ErrorHandlingStrategy { return when (error.severity) { ErrorSeverity.LOW -> ErrorHandlingStrategy.SHOW_TOAST ErrorSeverity.MEDIUM -> ErrorHandlingStrategy.SHOW_DIALOG ErrorSeverity.HIGH -> ErrorHandlingStrategy.SHOW_DIALOG ErrorSeverity.CRITICAL -> ErrorHandlingStrategy.FALLBACK } } override fun canHandle(errorType: ErrorType): Boolean = errorType == ErrorType.IMAGE_PROCESSING } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/handlers/NetworkErrorHandler.kt ================================================ package cn.netdiscovery.monica.exception.handlers import cn.netdiscovery.monica.exception.* /** * 网络错误处理器 * @FileName: * cn.netdiscovery.monica.exception.handlers.NetworkErrorHandler * @author: Tony Shen * @date: 2025/9/28 10:30 * @version: V1.0 <描述当前版本功能> */ class NetworkErrorHandler : ErrorHandler { override fun handleError(error: AppError): ErrorHandlingStrategy { return when (error.severity) { ErrorSeverity.LOW -> ErrorHandlingStrategy.SHOW_TOAST ErrorSeverity.MEDIUM -> ErrorHandlingStrategy.SHOW_DIALOG ErrorSeverity.HIGH -> if (error.retryable) ErrorHandlingStrategy.RETRY else ErrorHandlingStrategy.SHOW_DIALOG ErrorSeverity.CRITICAL -> ErrorHandlingStrategy.FALLBACK } } override fun canHandle(errorType: ErrorType): Boolean = errorType == ErrorType.NETWORK_ERROR } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/exception/handlers/ValidationErrorHandler.kt ================================================ package cn.netdiscovery.monica.exception.handlers import cn.netdiscovery.monica.exception.* /** * 验证错误处理器 * @FileName: * cn.netdiscovery.monica.exception.handlers.ValidationErrorHandler * @author: Tony Shen * @date: 2025/9/28 10:30 * @version: V1.0 <描述当前版本功能> */ class ValidationErrorHandler : ErrorHandler { override fun handleError(error: AppError): ErrorHandlingStrategy { return when (error.severity) { ErrorSeverity.LOW -> ErrorHandlingStrategy.SHOW_TOAST ErrorSeverity.MEDIUM -> ErrorHandlingStrategy.SHOW_TOAST ErrorSeverity.HIGH -> ErrorHandlingStrategy.SHOW_DIALOG ErrorSeverity.CRITICAL -> ErrorHandlingStrategy.SHOW_DIALOG } } override fun canHandle(errorType: ErrorType): Boolean = errorType == ErrorType.VALIDATION_ERROR } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/history/EditHistoryCenter.kt ================================================ package cn.netdiscovery.monica.history import cn.netdiscovery.monica.config.KEY_GENERAL_SETTINGS import cn.netdiscovery.monica.config.category.ConfigCategoryManager import cn.netdiscovery.monica.domain.GeneralSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers /** * * @FileName: * cn.netdiscovery.monica.history.EditHistoryCenter * @author: Tony Shen * @date: 2025/7/28 13:47 * @version: V1.0 全局的编辑历史协调器,用于管理多个模块的历史记录,例如图像调整、涂鸦 等。 */ object EditHistoryCenter { private val historyMap = mutableMapOf>() /** * 获取 GeneralSettings.maxHistorySize,带默认值 */ private fun getMaxHistorySize(): Int { val defaultSettings = GeneralSettings(255, 255, 255, 512, 50, "", "", "", "LIGHT") val settings = ConfigCategoryManager.load(KEY_GENERAL_SETTINGS, defaultSettings) return settings.maxHistorySize } @Suppress("UNCHECKED_CAST") fun getManager(key: String, scope: CoroutineScope? = null): EditHistoryManager { return historyMap.getOrPut(key) { val maxHistorySize = getMaxHistorySize() EditHistoryManager(maxHistorySize=maxHistorySize, coroutineScope = scope ?: CoroutineScope(Dispatchers.Default)) } as EditHistoryManager } fun clearAll() { historyMap.values.forEach { it.clear() } historyMap.clear() } fun remove(key: String) { historyMap.remove(key) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/history/EditHistoryManager.kt ================================================ package cn.netdiscovery.monica.history import cn.netdiscovery.monica.utils.logger import kotlinx.coroutines.* import org.slf4j.Logger /** * * @FileName: * cn.netdiscovery.monica.history.EditHistoryManager * @author: Tony Shen * @date: 2025/7/28 13:40 * @version: V1.0 管理每个编辑会话的历史记录栈,包括撤销和重做功能。 */ class EditHistoryManager( private val maxHistorySize: Int = 20, private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default) ) { private val logger: Logger = logger>() private val undoStack = ArrayDeque>() // 历史状态栈 private val redoStack = ArrayDeque>() // 可重做的状态 private val operationLog = mutableListOf() // 日志记录 val canUndo: Boolean get() = undoStack.size > 1 // 至少有一个可撤销 val canRedo: Boolean get() = redoStack.isNotEmpty() private var debounceJob: Job? = null private val debounceDelayMillis = 300L /** * 清空所有历史记录 */ fun clear() { undoStack.clear() redoStack.clear() operationLog.clear() } /** * 记录一次新的编辑状态。 * 新状态会成为当前状态,并清空 redo 栈。 */ fun push(state: T, entry: HistoryEntry) { // 避免重复状态 (例如连续相同参数的调色) val last = undoStack.lastOrNull()?.first if (last != null && last == state) { return } // 超出容量则移除最早的 if (undoStack.size >= maxHistorySize) { undoStack.removeFirst() } undoStack.addLast(state to entry) redoStack.clear() } /** * 只记录操作日志(不会影响撤销栈) */ fun logOnly(entry: HistoryEntry) { operationLog.add(entry) if (operationLog.size > maxHistorySize) { operationLog.removeAt(0) } } fun getOperationLog(): List = operationLog.toList() /** * 防抖 push,避免频繁记录。 */ fun pushDebouncedAsync( entry: HistoryEntry, block: suspend () -> T, onError: ((Throwable) -> Unit)? = null ) { debounceJob?.cancel() debounceJob = coroutineScope.launch { delay(debounceDelayMillis) try { val state = block() withContext(Dispatchers.Main) { push(state, entry) } } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.error("pushDebouncedAsync failed: ${e.message}", e) onError?.invoke(e) } } } /** * 撤销一次操作,返回撤销后的状态(上一个状态)。 */ fun undo(): Pair? { if (canUndo) { val last = undoStack.removeLast() redoStack.addLast(last) return undoStack.lastOrNull() } return null } /** * 重做(恢复上一次撤销的状态) */ fun redo(): Pair? { if (canRedo) { val next = redoStack.removeLast() undoStack.addLast(next) return next } return null } /** * 查看上一个状态(不修改当前指针) */ fun previousState(): Pair? { return if (canUndo) { val iterator = undoStack.iterator() var prev: Pair? = null while (iterator.hasNext()) { val current = iterator.next() if (!iterator.hasNext()) break // 到最后一个时退出 prev = current } prev } else null } fun peekUndoEntry(): HistoryEntry? = undoStack.lastOrNull()?.second fun peekRedoEntry(): HistoryEntry? = redoStack.lastOrNull()?.second } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/history/HistoryEntry.kt ================================================ package cn.netdiscovery.monica.history import java.util.* /** * * @FileName: * cn.netdiscovery.monica.history.HistoryEntry * @author: Tony Shen * @date: 2025/7/26 10:21 * @version: V1.0 记录对象 */ data class HistoryEntry( val id: String = UUID.randomUUID().toString(), val timestamp: Long = System.currentTimeMillis(), val module: String, val operation: String, val parameters: Map, val previewImagePath: String = "", val sourceImageHash: String = "", val description: String = "" ) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/history/modules/colorcorrection/ColorCorrectionParams.kt ================================================ package cn.netdiscovery.monica.history.modules.colorcorrection import cn.netdiscovery.monica.config.MODULE_COLOR import cn.netdiscovery.monica.domain.ColorCorrectionSettings import cn.netdiscovery.monica.history.EditHistoryManager import cn.netdiscovery.monica.history.HistoryEntry /** * * @FileName: * cn.netdiscovery.monica.history.modules.colorcorrection.ColorCorrectionParams * @author: Tony Shen * @date: 2025/7/27 13:16 * @version: V1.0 调色参数封装 */ data class ColorCorrectionParams( val contrast: Int = 255, val hue: Int = 180, val saturation: Int = 255, val lightness: Int = 255, val temperature: Int = 255, val highlight: Int = 255, val shadow: Int = 255, val sharpen: Int = 0, val corner: Int = 0, val status: Int = 0 // 1 ~ 9 表示最近调整项,可选 ) { fun toMap(): Map = mapOf( "contrast" to contrast, "hue" to hue, "saturation" to saturation, "lightness" to lightness, "temperature" to temperature, "highlight" to highlight, "shadow" to shadow, "sharpen" to sharpen, "corner" to corner, "status" to status ) fun toSettings(): ColorCorrectionSettings = ColorCorrectionSettings( contrast, hue, saturation, lightness, temperature, highlight, shadow, sharpen, corner, status ) companion object { fun fromMap(map: Map): ColorCorrectionParams = ColorCorrectionParams( contrast = (map["contrast"] as? Number)?.toInt() ?: 255, hue = (map["hue"] as? Number)?.toInt() ?: 180, saturation = (map["saturation"] as? Number)?.toInt() ?: 255, lightness = (map["lightness"] as? Number)?.toInt() ?: 255, temperature = (map["temperature"] as? Number)?.toInt() ?: 255, highlight = (map["highlight"] as? Number)?.toInt() ?: 255, shadow = (map["shadow"] as? Number)?.toInt() ?: 255, sharpen = (map["sharpen"] as? Number)?.toInt() ?: 0, corner = (map["corner"] as? Number)?.toInt() ?: 0, status = (map["status"] as? Number)?.toInt() ?: 0 ) fun fromSettings(settings: ColorCorrectionSettings): ColorCorrectionParams = ColorCorrectionParams( contrast = settings.contrast, hue = settings.hue, saturation = settings.saturation, lightness = settings.lightness, temperature = settings.temperature, highlight = settings.highlight, shadow = settings.shadow, sharpen = settings.sharpen, corner = settings.corner, status = settings.status ) } } fun EditHistoryManager.recordColorCorrection( module: String = MODULE_COLOR, operation: String, description: String = "", isPush: Boolean = true, colorCorrectionSettings: ColorCorrectionSettings ) { val params = ColorCorrectionParams.fromSettings(colorCorrectionSettings) val entry = HistoryEntry(module = module, operation = operation, parameters = params.toMap(), description = description) if (isPush) { push(params as T, entry) } logOnly(entry) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/history/modules/opencv/CVParams.kt ================================================ package cn.netdiscovery.monica.history.modules.opencv import cn.netdiscovery.monica.config.MODULE_OPENCV import cn.netdiscovery.monica.history.EditHistoryManager import cn.netdiscovery.monica.history.HistoryEntry /** * * @FileName: * cn.netdiscovery.monica.history.modules.opencv.CVParams * @author: Tony Shen * @date: 2025/7/29 17:17 * @version: V1.0 <描述当前版本功能> */ data class CVParams( val operation: String = "", val parameters: MutableMap = HashMap() ) fun EditHistoryManager.recordCVOperation( module: String = MODULE_OPENCV, operation: String, description: String = "", buildParams: CVParams.() -> Unit ) { val params = CVParams(operation).apply(buildParams) val entry = HistoryEntry(module = module, operation = operation, parameters = params.parameters, description = description) push(params as T, entry) logOnly(entry) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/http/GsonSerializer.kt ================================================ package cn.netdiscovery.monica.http import cn.netdiscovery.http.core.serializer.Serializer import com.google.gson.Gson import java.lang.reflect.Type /** * * @FileName: * cn.netdiscovery.monica.http.GsonSerializer * @author: Tony Shen * @date: 2025/4/3 18:58 * @version: V1.0 <描述当前版本功能> */ class GsonSerializer: Serializer { private val gson: Gson = Gson() override fun fromJson(json: String, type: Type): T = gson.fromJson(json,type) override fun toJson(data: Any): String = gson.toJson(data) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/http/HttpClient.kt ================================================ package cn.netdiscovery.monica.http import cn.netdiscovery.http.core.HttpClientBuilder import cn.netdiscovery.http.core.request.converter.GlobalRequestJSONConverter import cn.netdiscovery.http.core.response.StringResponseMapper import cn.netdiscovery.http.core.utils.extension.asyncCall import cn.netdiscovery.http.interceptor.LoggingInterceptor import cn.netdiscovery.http.interceptor.log.LogManager import cn.netdiscovery.http.interceptor.log.LogProxy import cn.netdiscovery.monica.utils.CVFailure import cn.netdiscovery.monica.utils.CVSuccess import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Request import okhttp3.RequestBody import okio.BufferedSink import org.slf4j.Logger import org.slf4j.LoggerFactory import java.awt.image.BufferedImage import java.io.ByteArrayInputStream import java.io.IOException import java.util.concurrent.TimeUnit import javax.imageio.ImageIO /** * * @FileName: * cn.netdiscovery.monica.http.HttpClient * @author: Tony Shen * @date: 2025/3/26 16:45 * @version: V1.0 <描述当前版本功能> */ const val DEFAULT_CONN_TIMEOUT = 30 private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) val loggingInterceptor by lazy { LogManager.logProxy(object : LogProxy { // 必须要实现 LogProxy ,否则无法打印网络请求的 request 、response override fun e(tag: String, msg: String) { } override fun w(tag: String, msg: String) { } override fun i(tag: String, msg: String) { logger.info("$tag:$msg") } override fun d(tag: String, msg: String) { logger.info("$tag:$msg") } }) LoggingInterceptor.Builder() .loggable(true) // TODO: 发布到生产环境需要改成false .request() .requestTag("Request") .response() .responseTag("Response") // .hideVerticalLine()// 隐藏竖线边框 .build() } val httpClient by lazy { HttpClientBuilder() .allTimeouts(DEFAULT_CONN_TIMEOUT.toLong(), TimeUnit.SECONDS) .addInterceptor(loggingInterceptor) .serializer(GsonSerializer()) .jsonConverter(GlobalRequestJSONConverter::class) .responseMapper(StringResponseMapper::class) .build() } fun healthCheck(baseUrl:String):Boolean = httpClient.get(url = "${baseUrl}health").code == 200 /** * 封装 RequestBody */ fun createRequestBody(image: BufferedImage, format:String): RequestBody { return object : RequestBody() { override fun contentType(): MediaType? { return "image/jpeg".toMediaTypeOrNull() } override fun writeTo(sink: BufferedSink) { // 使用 try-with-resources 确保流关闭 val outputStream = sink.outputStream() outputStream.use { if (!ImageIO.write(image, format, it)) { throw IOException("Unsupported image format: $format") } } } } } /** * 封装 http 请求 */ fun createRequest(request: ()->Request, success: CVSuccess, failure: CVFailure) { try { httpClient.okHttpClient() .asyncCall { request.invoke() } .get() .use { response-> val bufferedImage = ByteArrayInputStream(response.body?.bytes()).use { inputStream -> ImageIO.read(inputStream) } success.invoke(bufferedImage) } } catch (e:Exception){ e.printStackTrace() failure.invoke(e) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/llm/DeepSeekRequest.kt ================================================ package cn.netdiscovery.monica.llm /** * * @FileName: * cn.netdiscovery.monica.llm.DeepSeekRequest * @author: Tony Shen * @date: 2025/8/2 17:08 * @version: V1.0 <描述当前版本功能> */ data class DeepSeekRequest( val model: String, val messages: List, val stream: Boolean ) data class DeepSeekMessage( val role: String, val content: String ) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/llm/DeepseekClient.kt ================================================ package cn.netdiscovery.monica.llm import cn.netdiscovery.http.core.utils.extension.asyncCall import cn.netdiscovery.monica.domain.ColorCorrectionSettings import cn.netdiscovery.monica.exception.MonicaException import cn.netdiscovery.monica.http.httpClient import com.safframework.rxcache.utils.GsonUtils import kotlinx.coroutines.runBlocking import okhttp3.Headers.Companion.toHeaders import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Request import okhttp3.RequestBody import org.json.JSONObject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.llm.DeepseekClient * @author: Tony Shen * @date: 2025/8/1 13:59 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) /** * 每次根据当前参数和新指令拼 prompt, 支持多轮对话 */ @Throws(MonicaException::class) fun applyInstructionWithLLM( session: DialogSession, instruction: String, apiKey: String ): ColorCorrectionSettings? { val prompt = buildString { append("当前图像的参数如下:\n") append(GsonUtils.toJson(session.currentSettings)) append("\n\n") append("用户指令:$instruction") } val messages = mutableListOf().apply { this.add(DeepSeekMessage(role = "system", content = systemPromptForColorCorrection)) this.add(DeepSeekMessage(role = "user", content = prompt)) } val deepSeekRequest = DeepSeekRequest("deepseek-chat", messages, false) val payload = GsonUtils.toJson(deepSeekRequest) val responseJson = sendPostJson( url = DEEPSEEK_URL, headers = mapOf("Content-Type" to "application/json", "Authorization" to "Bearer $apiKey"), body = payload ) try { val json = extractJson(responseJson) val responseObj = GsonUtils.fromJson(json, ColorCorrectionSettings::class.java) session.currentSettings = responseObj // 历史记录现在在 LLMServiceManager 中处理 return responseObj } catch (e: Exception) { logger.error("responseJson = $responseJson") logger.error(e.message, e) throw MonicaException("无法获取调色的参数") } } fun extractJson(jsonData: String): String { val jsonObject: JSONObject = JSONObject(jsonData) var content = jsonObject.getJSONArray("choices")?.getJSONObject(0)?.getJSONObject("message")?.get("content")?.toString()?:"" if (content.isNotEmpty()) { content = content.replace("```json","").replace("```","") return content } else { throw MonicaException("无法获取调色的参数") } } fun sendPostJson(url: String, headers: Map, body: Any): String { return runBlocking { val requestBody = RequestBody.create("application/json; charset=utf-8".toMediaTypeOrNull(), body.toString()) httpClient.okHttpClient() .asyncCall { Request.Builder() .url(url) .headers(headers.toHeaders()) .post(requestBody) .build() } .get().body?.string()?:"" } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/llm/DialogSession.kt ================================================ package cn.netdiscovery.monica.llm import cn.netdiscovery.monica.domain.ColorCorrectionSettings /** * * @FileName: * cn.netdiscovery.monica.llm.DialogSession * @author: Tony Shen * @date: 2025/8/1 17:27 * @version: V1.0 封装一个会话上下文类(保留系统提示 + 当前参数) */ data class DialogSession( val systemPrompt: String, var currentSettings: ColorCorrectionSettings, val history: MutableList = mutableListOf(), var lastUsedProvider: LLMProvider? = null // 记录上次使用的 LLM 提供商 ) /** * 调色历史记录项 */ data class ColorCorrectionHistoryItem( val userInstruction: String, val resultSettings: ColorCorrectionSettings, val usedProvider: LLMProvider ) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/llm/GeminiClient.kt ================================================ package cn.netdiscovery.monica.llm import cn.netdiscovery.http.core.utils.extension.asyncCall import cn.netdiscovery.monica.domain.ColorCorrectionSettings import cn.netdiscovery.monica.exception.MonicaException import cn.netdiscovery.monica.http.httpClient import com.safframework.rxcache.utils.GsonUtils import kotlinx.coroutines.runBlocking import okhttp3.Headers.Companion.toHeaders import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Request import okhttp3.RequestBody import org.json.JSONObject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.llm.GeminiClient * @author: Tony Shen * @date: 2025/9/4 16:30 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) /** * 使用 Gemini API 进行自然语言调色 */ @Throws(MonicaException::class) fun applyInstructionWithGemini( session: DialogSession, instruction: String, apiKey: String ): ColorCorrectionSettings? { val prompt = buildString { append("当前图像的参数如下:\n") append(GsonUtils.toJson(session.currentSettings)) append("\n\n") append("用户指令:$instruction") } val geminiRequest = GeminiRequest( contents = listOf( GeminiContent( parts = listOf( GeminiPart(text = systemPromptForColorCorrection + "\n\n" + prompt) ) ) ) ) val payload = GsonUtils.toJson(geminiRequest) val responseJson = sendPostJsonToGemini( url = "$GEMINI_URL$apiKey", headers = mapOf("Content-Type" to "application/json"), body = payload ) try { val json = extractJsonFromGemini(responseJson) val responseObj = GsonUtils.fromJson(json, ColorCorrectionSettings::class.java) session.currentSettings = responseObj // 历史记录现在在 LLMServiceManager 中处理 return responseObj } catch (e: Exception) { logger.error("responseJson = $responseJson") logger.error(e.message, e) throw MonicaException("无法获取调色的参数") } } fun extractJsonFromGemini(jsonData: String): String { val jsonObject: JSONObject = JSONObject(jsonData) var content = jsonObject.getJSONArray("candidates")?.getJSONObject(0)?.getJSONObject("content")?.getJSONArray("parts")?.getJSONObject(0)?.get("text")?.toString()?:"" if (content.isNotEmpty()) { content = content.replace("```json","").replace("```","") return content } else { throw MonicaException("无法获取调色的参数") } } fun sendPostJsonToGemini(url: String, headers: Map, body: Any): String { return runBlocking { val requestBody = RequestBody.create("application/json; charset=utf-8".toMediaTypeOrNull(), body.toString()) httpClient.okHttpClient() .asyncCall { Request.Builder() .url(url) .headers(headers.toHeaders()) .post(requestBody) .build() } .get().body?.string()?:"" } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/llm/GeminiRequest.kt ================================================ package cn.netdiscovery.monica.llm /** * * @FileName: * cn.netdiscovery.monica.llm.GeminiRequest * @author: Tony Shen * @date: 2025/9/4 16:30 * @version: V1.0 <描述当前版本功能> */ /** * Gemini API 请求数据类 */ data class GeminiRequest( val contents: List ) /** * Gemini API 内容数据类 */ data class GeminiContent( val parts: List ) /** * Gemini API 部分数据类 */ data class GeminiPart( val text: String ) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/llm/LLMServiceManager.kt ================================================ package cn.netdiscovery.monica.llm import androidx.compose.runtime.* import cn.netdiscovery.monica.domain.ColorCorrectionSettings import cn.netdiscovery.monica.exception.MonicaException import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.llm.LLMServiceManager * @author: Tony Shen * @date: 2025/9/4 17:45 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) val systemPromptForColorCorrection = "你是一个图像调色助手。用户会输入一句话,你需要将这句话转换为一组 JSON 格式的调色参数,字段说明如下:\\n\\n- contrast: 对比度,整数,范围 0 - 510,默认值 255。\\n- hue: 色调,整数,范围 0 - 360,默认值 180。\\n- saturation: 饱和度,整数,范围 0 - 510,默认值 255。\\n- lightness: 亮度,整数,范围 0 - 510,默认值 255。\\n- temperature: 色温,整数,范围 0 - 510,默认值 255。\\n- highlight: 高光,整数,范围 0 - 510,默认值 255。\\n- shadow: 阴影,整数,范围 0 - 510,默认值 255。\\n- sharpen: 锐化,整数,范围 0 - 255,默认值 0。\\n- corner: 暗角,整数,范围 0 - 255,默认值 0。\\n- status: 表示用户意图主要修改了哪一项(用于前端高亮显示),值如下:\\n - 1 表示 contrast\\n - 2 表示 hue\\n - 3 表示 saturation\\n - 4 表示 lightness\\n - 5 表示 temperature\\n - 6 表示 highlight\\n - 7 表示 shadow\\n - 8 表示 sharpen\\n - 9 表示 corner\\n\\n要求:\\n- 不要输出解释。\\n- 严格输出 JSON 格式,字段顺序与上方一致。\\n- 如果用户输入不涉及某些参数,请保留默认值。\\n- 请根据语义合理推测用户意图。".trimIndent() val DEEPSEEK_URL = "https://api.deepseek.com/chat/completions" val GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" /** * 记住 LLM 服务管理器实例 */ @Composable fun rememberLLMServiceManager(): LLMServiceManager { return remember { LLMServiceManager() } } /** * LLM 服务提供商枚举 */ enum class LLMProvider { DEEPSEEK, GEMINI } /** * LLM 服务管理器 * 统一管理不同的 LLM 服务提供商 */ class LLMServiceManager { /** * 使用指定的 LLM 服务提供商进行自然语言调色 * * @param provider LLM 服务提供商 * @param session 对话会话 * @param instruction 用户指令 * @param apiKey API 密钥 * @return 更新后的颜色校正设置 * @throws MonicaException 当调用失败时抛出异常 */ fun applyInstructionWithLLM( provider: LLMProvider, session: DialogSession, instruction: String, apiKey: String ): ColorCorrectionSettings? { val result = when (provider) { LLMProvider.DEEPSEEK -> { logger.info("使用 DeepSeek 进行自然语言调色") applyInstructionWithLLM(session, instruction, apiKey) } LLMProvider.GEMINI -> { logger.info("使用 Gemini 进行自然语言调色") applyInstructionWithGemini(session, instruction, apiKey) } } // 如果调用成功,记录历史记录 result?.let { settings -> val historyItem = ColorCorrectionHistoryItem( userInstruction = instruction, resultSettings = settings, usedProvider = provider ) session.history.add(historyItem) logger.info("记录调色历史: 使用 ${provider.name} 处理指令 '$instruction'") } return result } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/manager/OpenCVManager.kt ================================================ package cn.netdiscovery.monica.manager import cn.netdiscovery.monica.imageprocess.BufferedImages import cn.netdiscovery.monica.imageprocess.utils.extension.toImageInfo import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.CVAction import cn.netdiscovery.monica.utils.CVFailure import cn.netdiscovery.monica.utils.CVSuccess import kotlinx.coroutines.suspendCancellableCoroutine import java.awt.image.BufferedImage import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException /** * * @FileName: * cn.netdiscovery.monica.manager.OpenCVManager * @author: Tony Shen * @date: 2024/8/13 19:54 * @version: V1.0 */ object OpenCVManager { /** * 封装调用 OpenCV 的方法 * 便于"当前的图像"进行调用 OpenCV 的方法,以及对返回的 IntArray 进行处理返回成 BufferedImage * * @param state 当前应用的 state * @param type 生成图像的类型 * @param action 通过 jni 调用 OpenCV 的方法 * @param failure 失败的回调 */ fun invokeCV(state: ApplicationState, type:Int = BufferedImage.TYPE_INT_ARGB, action: CVAction, failure: CVFailure) { if (state.currentImage!=null) { val (width,height,byteArray) = state.currentImage!!.toImageInfo() try { val outPixels = action.invoke(byteArray) state.addQueue(state.currentImage!!) state.currentImage = BufferedImages.toBufferedImage(outPixels,width,height,type) } catch (e:Exception) { failure.invoke(e) } } } /** * 封装调用 OpenCV 的方法 * 便于对某个图像调用 OpenCV 的方法,以及对返回的 IntArray 进行处理返回成 BufferedImage * * @param image 对该图片进行处理 * @param type 生成图像的类型 * @param action 通过 jni 调用 OpenCV 的方法 * @param success 成功的回调 * @param failure 失败的回调 */ fun invokeCV(image: BufferedImage, type:Int = BufferedImage.TYPE_INT_ARGB, action: CVAction, success: CVSuccess, failure: CVFailure) { val (width,height,byteArray) = image.toImageInfo() try { val outPixels = action.invoke(byteArray) success.invoke(BufferedImages.toBufferedImage(outPixels,width,height,type)) } catch (e:Exception) { failure.invoke(e) } } suspend fun invokeCVSuspend( image: BufferedImage, type: Int = BufferedImage.TYPE_INT_ARGB, action: CVAction ): BufferedImage = suspendCancellableCoroutine { cont -> invokeCV( image = image, type = type, action = action, success = { result -> if (cont.isActive) cont.resume(result) }, failure = { e -> if (cont.isActive) cont.resumeWithException(e) } ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/state/ApplicationState.kt ================================================ package cn.netdiscovery.monica.state import androidx.compose.runtime.* import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.window.Notification import androidx.compose.ui.window.TrayState import cn.netdiscovery.monica.config.KEY_GENERAL_SETTINGS import cn.netdiscovery.monica.config.category.ConfigCategoryManager import cn.netdiscovery.monica.domain.DecodedPreviewImage import cn.netdiscovery.monica.i18n.LocalizationManager import cn.netdiscovery.monica.domain.GeneralSettings import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.ui.theme.ColorTheme import cn.netdiscovery.monica.ui.theme.ThemeManager import cn.netdiscovery.monica.utils.ImageFormat import kotlinx.coroutines.CoroutineScope import java.awt.image.BufferedImage import java.io.File import java.util.concurrent.LinkedBlockingDeque import java.util.concurrent.TimeUnit /** * * @FileName: * cn.netdiscovery.monica.state.ApplicationState * @author: Tony Shen * @date: 2024/4/26 10:42 * @version: V1.0 <描述当前版本功能> */ const val ZoomPreviewStatus: Int = 1 const val BlurStatus: Int = 2 const val MosaicStatus: Int = 3 const val DoodleStatus: Int = 4 const val ShapeDrawingStatus: Int = 5 const val ColorPickStatus: Int = 6 const val GenerateGifStatus: Int = 7 const val FlipStatus: Int = 8 const val RotateStatus: Int = 9 const val ResizeStatus: Int = 10 const val ShearingStatus: Int = 11 const val CropSizeStatus: Int = 12 const val CompressionStatus: Int = 13 const val ColorCorrectionStatus: Int = 14 const val FilterStatus: Int = 15 const val OpenCVDebugStatus: Int = 16 const val FaceDetectStatus: Int = 17 const val SketchDrawingStatus: Int = 18 const val FaceSwapStatus: Int = 19 const val CartoonStatus: Int = 20 const val WebScreenshotStatus: Int = 21 @Composable fun rememberApplicationState( scope: CoroutineScope, trayState: TrayState ) = remember { ApplicationState(scope, trayState) } class ApplicationState(val scope:CoroutineScope, val trayState: TrayState) { lateinit var window: ComposeWindow var rawImage: BufferedImage? by mutableStateOf(null) var currentImage: BufferedImage? by mutableStateOf( rawImage ) var rawImageFile: File? = null var rawImageFormat: ImageFormat? = null var nativeImageInfo: DecodedPreviewImage? = null var nativeFullImageProcessed: Boolean = false // 表示用于点击了哪个功能 var currentStatus by mutableStateOf(0) var isGeneralSettings by mutableStateOf(false) var isBasic by mutableStateOf(false) var isColorCorrection by mutableStateOf(false) var isFilter by mutableStateOf(false) var isAI by mutableStateOf(false) var isCompression by mutableStateOf(false) var isShowPreviewWindow by mutableStateOf(false) private val queue: LinkedBlockingDeque = LinkedBlockingDeque(40) // 通用输出框的颜色 private val defaultSettings = GeneralSettings(255, 255, 255, 512, 50, "", "", "", "LIGHT") private fun loadGeneralSettings(): GeneralSettings { return ConfigCategoryManager.load(KEY_GENERAL_SETTINGS, defaultSettings) } private val initialSettings = loadGeneralSettings() var outputBoxRText by mutableStateOf(initialSettings.outputBoxR) var outputBoxGText by mutableStateOf(initialSettings.outputBoxG) var outputBoxBText by mutableStateOf(initialSettings.outputBoxB) var sizeText by mutableStateOf(initialSettings.size) var maxHistorySizeText by mutableStateOf(initialSettings.maxHistorySize) var deepSeekApiKeyText by mutableStateOf(initialSettings.deepSeekApiKey) var geminiApiKeyText by mutableStateOf(initialSettings.geminiApiKey) var algorithmUrlText by mutableStateOf(initialSettings.algorithmUrl) // 主题设置 - 作为唯一的状态源 var currentTheme by mutableStateOf( initialSettings.themeId.let { themeId -> ThemeManager.getThemeById(themeId) ?: ColorTheme.LIGHT } ) // 初始化时同步到ThemeManager init { ThemeManager.setCurrentTheme(currentTheme) } fun toOutputBoxScalar() = intArrayOf(outputBoxBText, outputBoxGText, outputBoxRText) fun saveGeneralSettings() { val settings = GeneralSettings( outputBoxRText, outputBoxGText, outputBoxBText, sizeText, maxHistorySizeText, deepSeekApiKeyText, geminiApiKeyText, algorithmUrlText, currentTheme.getThemeId() ) ConfigCategoryManager.save(KEY_GENERAL_SETTINGS, settings) } /** * 切换主题 - 确保状态同步 */ fun setTheme(theme: ColorTheme) { currentTheme = theme ThemeManager.setCurrentTheme(theme) // 立即保存到缓存 saveGeneralSettings() } /** * 获取当前主题 */ fun getCurrentThemeValue(): ColorTheme = currentTheme fun getLastImage():BufferedImage? = queue.pollFirst(1, TimeUnit.SECONDS) fun addQueue(bufferedImage: BufferedImage) { queue.putFirst(bufferedImage) } fun clearQueue() { queue.clear() } fun togglePreviewWindow(isShow: Boolean = true) { isShowPreviewWindow = isShow } /** * 弹出新的页面,更新 currentStatus 状态 * @param status 更新为当前的状态 */ fun togglePreviewWindowAndUpdateStatus(status:Int) { currentStatus = status isShowPreviewWindow = true } /** * 关闭当前弹出的页面 */ fun closePreviewWindow() { resetCurrentStatus() togglePreviewWindow(false) } /** * 清空了当前的状态 */ fun resetCurrentStatus() { currentStatus = 0 } fun clearImage() { this.rawImage = null this.currentImage = null this.rawImageFile = null this.rawImageFormat = null this.nativeFullImageProcessed = false val nativePtr = this.nativeImageInfo?.nativePtr if (nativePtr!=null && nativePtr !=0L) { ImageProcess.deletePyramidImage(nativePtr) } this.nativeImageInfo = null } fun showTray( msg: String, title: String = LocalizationManager.getString("notification"), type: Notification.Type = Notification.Type.Info ) { val notification = Notification(title, msg, type) trayState.sendNotification(notification) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/BasicView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.exception.ErrorSeverity import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.state.* import cn.netdiscovery.monica.ui.preview.PreviewViewModel import cn.netdiscovery.monica.ui.widget.* import cn.netdiscovery.monica.utils.getValidateField import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.BasicView * @author: Tony Shen * @date: 2024/5/1 00:39 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) @Composable fun basicView(state: ApplicationState) { val i18nState = rememberI18nState() val viewModel: PreviewViewModel = koinInject() Row(verticalAlignment = Alignment.CenterVertically) { // 图像模糊 toolTipButton( text = i18nState.getString("image_blur"), painter = painterResource("images/controlpanel/blur.png"), enable = { state.isBasic }, onClick = { state.currentStatus = BlurStatus }) // 图像马赛克 toolTipButton( text = i18nState.getString("image_mosaic"), painter = painterResource("images/controlpanel/mosaic.png"), enable = { state.isBasic }, onClick = { state.currentStatus = MosaicStatus }) // 图像涂鸦 toolTipButton( text = i18nState.getString("image_doodle"), painter = painterResource("images/controlpanel/doodle.png"), enable = { state.isBasic }, onClick = { state.togglePreviewWindowAndUpdateStatus(DoodleStatus) }) // 形状绘制 toolTipButton( text = i18nState.getString("shape_drawing"), painter = painterResource("images/controlpanel/shape-drawing.png"), enable = { state.isBasic }, onClick = { state.togglePreviewWindowAndUpdateStatus(ShapeDrawingStatus) }) } Row(verticalAlignment = Alignment.CenterVertically) { // 图像取色 toolTipButton(text = i18nState.getString("color_picker"), painter = painterResource("images/controlpanel/color-picker.png"), enable = { state.isBasic }, onClick = { state.togglePreviewWindowAndUpdateStatus(ColorPickStatus) }) // 生成gif toolTipButton(text = i18nState.getString("generate_gif"), painter = painterResource("images/controlpanel/gif.png"), enable = { state.isBasic }, onClick = { state.togglePreviewWindowAndUpdateStatus(GenerateGifStatus) }) // 图像翻转 toolTipButton(text = i18nState.getString("image_flip"), painter = painterResource("images/controlpanel/flip.png"), enable = { state.isBasic }, onClick = { state.currentStatus = FlipStatus viewModel.flip(state) }) // 图像旋转 toolTipButton(text = i18nState.getString("image_rotate"), painter = painterResource("images/controlpanel/rotate.png"), enable = { state.isBasic }, onClick = { state.currentStatus = RotateStatus viewModel.rotate(state) }) } Row(verticalAlignment = Alignment.CenterVertically) { // 图像缩放 toolTipButton(text = i18nState.getString("image_scale"), painter = painterResource("images/controlpanel/resize.png"), enable = { state.isBasic }, onClick = { state.currentStatus = ResizeStatus }) // 图像错切 toolTipButton(text = i18nState.getString("image_shear"), painter = painterResource("images/controlpanel/shearing.png"), enable = { state.isBasic }, onClick = { state.currentStatus = ShearingStatus }) // 图像裁剪 toolTipButton(text = i18nState.getString("image_crop"), painter = painterResource("images/controlpanel/crop.png"), enable = { state.isBasic }, onClick = { state.togglePreviewWindowAndUpdateStatus(CropSizeStatus) }) // 图像压缩 toolTipButton(text = i18nState.getString("image_compression"), painter = painterResource("images/controlpanel/compress.png"), enable = { state.isBasic }, onClick = { state.togglePreviewWindowAndUpdateStatus(CompressionStatus) }) } Column { when(state.currentStatus) { ResizeStatus -> generateResizeParams(state,viewModel) ShearingStatus -> generateShearingParams(state,viewModel) } } } @Composable private fun generateResizeParams(state: ApplicationState, viewModel: PreviewViewModel) { val i18nState = rememberI18nState() var widthText by remember { mutableStateOf("${state.currentImage?.width?:400}") } var heightText by remember { mutableStateOf("${state.currentImage?.height?:400}") } Column { basicTextFieldWithTitle(titleText = "width", widthText, Modifier.padding(top = 5.dp)) { str -> widthText = str } basicTextFieldWithTitle(titleText = "height", heightText, Modifier.padding(top = 5.dp)) { str -> heightText = str } } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { confirmButton(state.isBasic) { val width = getValidateField(block = { widthText.toInt() } , failed = { val errorMsg = i18nState.getString("width_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@confirmButton val height = getValidateField(block = { heightText.toInt() } , failed = { val errorMsg = i18nState.getString("height_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@confirmButton viewModel.resize(width, height, state) } } } @Composable private fun generateShearingParams(state: ApplicationState, viewModel: PreviewViewModel) { val i18nState = rememberI18nState() var xText by remember { mutableStateOf("${0}") } var yText by remember { mutableStateOf("${0}") } Column { basicTextFieldWithTitle(titleText = i18nState.getString("x_direction"), xText, Modifier.padding(top = 5.dp)) { str -> xText = str } basicTextFieldWithTitle(titleText = i18nState.getString("y_direction"), yText, Modifier.padding(top = 5.dp)) { str -> yText = str } } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { confirmButton(state.isBasic) { val x = getValidateField(block = { xText.toFloat() } , failed = { val errorMsg = i18nState.getString("x_direction_needs_float") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@confirmButton val y = getValidateField(block = { yText.toFloat() } , failed = { val errorMsg = i18nState.getString("y_direction_needs_float") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@confirmButton viewModel.shearing(x, y, state) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/AIView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai import androidx.compose.foundation.layout.Row import androidx.compose.material.Checkbox import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.state.* import cn.netdiscovery.monica.ui.widget.subTitle import cn.netdiscovery.monica.ui.widget.toolTipButton import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.AIView * @author: Tony Shen * @date: 2024/7/27 11:13 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) @Composable fun aiView(state: ApplicationState) { val i18nState = rememberI18nState() val viewModel: AIViewModel = koinInject() Row ( verticalAlignment = Alignment.CenterVertically ) { toolTipButton( text = i18nState.getString("simple_cv_algorithm"), painter = painterResource("images/ai/experiment.png"), enable = { state.isAI }, onClick = { state.togglePreviewWindowAndUpdateStatus(OpenCVDebugStatus) }) toolTipButton( text = i18nState.getString("face_detection"), painter = painterResource("images/ai/face_detect.png"), enable = { state.isAI }, onClick = { state.currentStatus = FaceDetectStatus viewModel.faceDetect(state) }) toolTipButton( text = i18nState.getString("generate_sketch"), painter = painterResource("images/ai/sketch_drawing.png"), enable = { state.isAI }, onClick = { state.currentStatus = SketchDrawingStatus viewModel.sketchDrawing(state) }) toolTipButton(text = i18nState.getString("face_swap"), painter = painterResource("images/ai/face_swap.png"), enable = { state.isAI }, onClick = { state.togglePreviewWindowAndUpdateStatus(FaceSwapStatus) }) } Row ( verticalAlignment = Alignment.CenterVertically ) { toolTipButton(text = i18nState.getString("anime_style"), painter = painterResource("images/ai/cartoon.png"), enable = { state.isAI }, onClick = { state.togglePreviewWindowAndUpdateStatus(CartoonStatus) }) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/AIViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai import cn.netdiscovery.monica.exception.ErrorSeverity import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.http.createRequest import cn.netdiscovery.monica.http.createRequestBody import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.ImageFormatDetector import cn.netdiscovery.monica.utils.extensions.launchWithSuspendLoading import cn.netdiscovery.monica.utils.logger import okhttp3.Request import okhttp3.RequestBody import org.slf4j.Logger /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.AIViewModel * @author: Tony Shen * @date: 2024/7/28 11:21 * @version: V1.0 <描述当前版本功能> */ class AIViewModel { private val logger: Logger = logger() fun faceDetect(state: ApplicationState) { if (state.currentImage == null) return state.scope.launchWithSuspendLoading { createRequest(request = { val format = ImageFormatDetector.getImageFormat(state.rawImageFile!!)?:"jpg" val requestBody: RequestBody = createRequestBody(state.currentImage!!,format) Request.Builder() .url( "${state.algorithmUrlText}api/faceDetect") .post(requestBody) .build() }, success = { state.addQueue(state.currentImage!!) state.currentImage = it }, failure = { logger.error(it.message) showError(ErrorType.AI_SERVICE_ERROR, ErrorSeverity.MEDIUM, "算法服务异常", "算法服务异常") }) } } fun sketchDrawing(state: ApplicationState) { if (state.currentImage == null) return state.scope.launchWithSuspendLoading { createRequest(request = { val format = ImageFormatDetector.getImageFormat(state.rawImageFile!!)?:"jpg" val requestBody: RequestBody = createRequestBody(state.currentImage!!,format) Request.Builder() .url( "${state.algorithmUrlText}api/sketchDrawing") .post(requestBody) .build() }, success = { state.addQueue(state.currentImage!!) state.currentImage = it }, failure = { logger.error(it.message) showError(ErrorType.AI_SERVICE_ERROR, ErrorSeverity.MEDIUM, "算法服务异常", "算法服务异常") }) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/BinaryImageView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.Button import androidx.compose.material.RadioButton import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.BinaryImageViewModel import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.EdgeDetectionViewModel import cn.netdiscovery.monica.ui.widget.* import cn.netdiscovery.monica.utils.getValidateField import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.ErrorSeverity import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.BinaryImageAnalysisView * @author: Tony Shen * @date: 2024/10/2 15:03 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) val typeSelectTags = arrayListOf("THRESH_BINARY", "THRESH_BINARY_INV") val thresholdSelectTags = arrayListOf("THRESH_OTSU", "THRESH_TRIANGLE") val adaptiveMethodSelectTags = arrayListOf("ADAPTIVE_THRESH_MEAN_C", "ADAPTIVE_THRESH_GAUSSIAN_C") @Composable fun binaryImage(state: ApplicationState, title: String) { val i18nState = rememberI18nState() val viewModel: BinaryImageViewModel = koinInject() val edgeDetectionViewModel: EdgeDetectionViewModel = koinInject() var typeSelectedOption by remember { mutableStateOf("Null") } var thresholdSelectedOption by remember { mutableStateOf("Null") } var adaptiveMethodSelectedOption by remember { mutableStateOf("Null") } var blockSizeText by remember { mutableStateOf("") } var cText by remember { mutableStateOf("") } var threshold1Text by remember { mutableStateOf("") } var threshold2Text by remember { mutableStateOf("") } var apertureSizeText by remember { mutableStateOf("3") } var hminText by remember { mutableStateOf("") } var sminText by remember { mutableStateOf("") } var vminText by remember { mutableStateOf("") } var hmaxText by remember { mutableStateOf("") } var smaxText by remember { mutableStateOf("") } var vmaxText by remember { mutableStateOf("") } fun clearAdaptiveThreshParams() { blockSizeText = "" cText = "" } Column (modifier = Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 10.dp)) { title(modifier = Modifier.align(Alignment.CenterHorizontally) , text = title, color = Color.Black) Column { subTitleWithDivider(text = i18nState.getString("grayscale_image"), color = Color.Black) Button( modifier = Modifier.align(Alignment.End), onClick = experimentViewClick(state) { if(state.currentImage!= null && state.currentImage?.type != BufferedImage.TYPE_BYTE_GRAY) { viewModel.cvtGray(state) } } ) { Text(text = i18nState.getString("image_grayscale"), color = Color.Unspecified) } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("threshold_segmentation"), color = Color.Black) checkBoxWithTitle(i18nState.getString("threshold_type"), checked = CVState.isThreshType, onCheckedChange = { CVState.isThreshType = it if (!CVState.isThreshType) { typeSelectedOption = "Null" logger.info("取消了阈值化类型") } else { logger.info("勾选了阈值化类型") } }) Row { typeSelectTags.forEach { RadioButton( selected = (CVState.isThreshType && it == typeSelectedOption), onClick = { typeSelectedOption = it } ) Text(text = it, modifier = Modifier.align(Alignment.CenterVertically)) } } checkBoxWithTitle(i18nState.getString("global_threshold_segmentation"), modifier = Modifier.padding(top = 10.dp), checked = CVState.isThreshSegment, onCheckedChange = { CVState.isThreshSegment = it if (!CVState.isThreshSegment) { thresholdSelectedOption = "Null" logger.info("取消了全局阈值分割") } else { CVState.isAdaptiveThresh = false adaptiveMethodSelectedOption = "Null" clearAdaptiveThreshParams() logger.info("勾选了全局阈值分割") } }) Row { thresholdSelectTags.forEach { RadioButton( selected = (CVState.isThreshSegment && it == thresholdSelectedOption), onClick = { thresholdSelectedOption = it } ) Text(text = it, modifier = Modifier.align(Alignment.CenterVertically)) } } checkBoxWithTitle(i18nState.getString("adaptive_threshold_segmentation"), modifier = Modifier.padding(top = 10.dp), checked = CVState.isAdaptiveThresh, onCheckedChange = { CVState.isAdaptiveThresh = it if (!CVState.isAdaptiveThresh) { adaptiveMethodSelectedOption = "Null" clearAdaptiveThreshParams() logger.info("取消了自适应阈值分割") } else { CVState.isThreshSegment = false thresholdSelectedOption = "Null" logger.info("勾选了自适应阈值分割") } }) Row { Text(i18nState.getString("adaptive_threshold_algorithm"), modifier = Modifier.align(Alignment.CenterVertically)) adaptiveMethodSelectTags.forEach { RadioButton( selected = (CVState.isAdaptiveThresh && it == adaptiveMethodSelectedOption), onClick = { adaptiveMethodSelectedOption = it } ) Text(text = it, modifier = Modifier.align(Alignment.CenterVertically)) } } Row { basicTextFieldWithTitle(titleText = "blockSize", blockSizeText) { str -> if (CVState.isAdaptiveThresh) { blockSizeText = str } } basicTextFieldWithTitle(titleText = "c", cText) { str -> if (CVState.isAdaptiveThresh) { cText = str } } } Button( modifier = Modifier.padding(top = 10.dp).align(Alignment.End), onClick = experimentViewClick(state) { if(state.currentImage?.type != BufferedImage.TYPE_BYTE_BINARY) { if (CVState.isThreshType && CVState.isThreshSegment) { if (typeSelectedOption == "Null") { val errorMsg = i18nState.getString("please_select_threshold_type") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) return@experimentViewClick } if (thresholdSelectedOption == "Null") { val errorMsg = i18nState.getString("please_select_global_threshold_segmentation") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) return@experimentViewClick } viewModel.threshold(state, typeSelectedOption, thresholdSelectedOption) } else if (CVState.isThreshType && CVState.isAdaptiveThresh) { if (typeSelectedOption == "Null") { val errorMsg = i18nState.getString("please_select_threshold_type") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) return@experimentViewClick } if (adaptiveMethodSelectedOption == "Null") { val errorMsg = i18nState.getString("please_select_adaptive_threshold_algorithm") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) return@experimentViewClick } val blockSize = getValidateField(block = { blockSizeText.toInt() } , failed = { val errorMsg = i18nState.getString("block_size_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) })?: return@experimentViewClick val c = getValidateField(block = { cText.toInt() } , failed = { val errorMsg = i18nState.getString("c_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) })?: return@experimentViewClick viewModel.adaptiveThreshold(state, adaptiveMethodSelectedOption, typeSelectedOption, blockSize, c) } else { val errorMsg = i18nState.getString("please_select_threshold_type_and_segmentation") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) } } } ) { Text(text = i18nState.getString("threshold_segmentation"), color = Color.Unspecified) } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("canny_edge_detection"), color = Color.Black) Row(modifier = Modifier.padding(top = 10.dp)){ basicTextFieldWithTitle(titleText = "threshold1", threshold1Text) { str -> threshold1Text = str } basicTextFieldWithTitle(titleText = "threshold2", threshold2Text) { str -> threshold2Text = str } basicTextFieldWithTitle(titleText = "apertureSize", apertureSizeText) { str -> apertureSizeText = str } } Button( modifier = Modifier.padding(top = 10.dp).align(Alignment.End), onClick = experimentViewClick(state) { if(state.currentImage?.type != BufferedImage.TYPE_BYTE_BINARY) { val threshold1 = getValidateField(block = { threshold1Text.toDouble() } , failed = { val errorMsg = i18nState.getString("threshold1_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) })?: return@experimentViewClick val threshold2 = getValidateField(block = { threshold2Text.toDouble() } , failed = { val errorMsg = i18nState.getString("threshold2_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) })?: return@experimentViewClick val apertureSize = getValidateField(block = { apertureSizeText.toInt() } , failed = { val errorMsg = i18nState.getString("aperture_size_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) })?: return@experimentViewClick edgeDetectionViewModel.canny(state, threshold1, threshold2, apertureSize) } } ) { Text(text = i18nState.getString("canny_edge_detection"), color = Color.Unspecified) } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("color_image_segmentation"), color = Color.Black) Row(modifier = Modifier.padding(top = 10.dp)) { basicTextFieldWithTitle(titleText = "hmin", hminText) { str -> hminText = str } basicTextFieldWithTitle(titleText = "smin", sminText) { str -> sminText = str } basicTextFieldWithTitle(titleText = "vmin", vminText) { str -> vminText = str } } Row(modifier = Modifier.padding(top = 10.dp)){ basicTextFieldWithTitle(titleText = "hmax", hmaxText) { str -> hmaxText = str } basicTextFieldWithTitle(titleText = "smax", smaxText) { str -> smaxText = str } basicTextFieldWithTitle(titleText = "vmax", vmaxText) { str -> vmaxText = str } } Button( modifier = Modifier.padding(top = 10.dp).align(Alignment.End), onClick = experimentViewClick(state) { if(state.currentImage?.type!! in 1..9) { val hmin = getValidateField(block = { hminText.toInt() }, failed = { val errorMsg = i18nState.getString("hmin_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) })?: return@experimentViewClick val smin = getValidateField(block = { sminText.toInt() } , failed = { val errorMsg = i18nState.getString("smin_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) })?: return@experimentViewClick val vmin = getValidateField(block = { vminText.toInt() } , failed = { val errorMsg = i18nState.getString("vmin_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) })?: return@experimentViewClick val hmax = getValidateField(block = { hmaxText.toInt() } , failed = { val errorMsg = i18nState.getString("hmax_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) })?: return@experimentViewClick val smax = getValidateField(block = { smaxText.toInt() } , failed = { val errorMsg = i18nState.getString("smax_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) })?: return@experimentViewClick val vmax = getValidateField(block = { vmaxText.toInt() } , failed = { val errorMsg = i18nState.getString("vmax_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) })?: return@experimentViewClick viewModel.inRange(state, hmin, smin, vmin, hmax, smax, vmax) } } ) { Text(text = i18nState.getString("color_image_segmentation"), color = Color.Unspecified) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/CVState.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import cn.netdiscovery.monica.domain.ContourDisplaySettings import cn.netdiscovery.monica.domain.ContourFilterSettings import cn.netdiscovery.monica.domain.MatchTemplateSettings import cn.netdiscovery.monica.domain.MorphologicalOperationSettings import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.CVState * @author: Tony Shen * @date: 2024/10/27 18:40 * @version: V1.0 <描述当前版本功能> */ object CVState { var isThreshType by mutableStateOf(false) var isThreshSegment by mutableStateOf(false) var isAdaptiveThresh by mutableStateOf(false) var isFirstDerivativeOperator by mutableStateOf(false) var isSecondDerivativeOperator by mutableStateOf(false) var isCannyOperator by mutableStateOf(false) var isContourPerimeter by mutableStateOf(false) var isContourArea by mutableStateOf(false) var isContourRoundness by mutableStateOf(false) var isContourAspectRatio by mutableStateOf(false) var showOriginalImage by mutableStateOf(false) var showBoundingRect by mutableStateOf(false) var showMinAreaRect by mutableStateOf(false) var showCenter by mutableStateOf(false) var templateImage: BufferedImage? by mutableStateOf(null) /** * 清空状态 */ fun clearAllStatus() { isThreshType = false isThreshSegment = false isAdaptiveThresh = false isFirstDerivativeOperator = false isSecondDerivativeOperator = false isCannyOperator = false isContourPerimeter = false isContourArea = false isContourRoundness = false isContourAspectRatio = false showOriginalImage = false showBoundingRect = false showMinAreaRect = false showCenter = false templateImage = null contourFilterSettings = ContourFilterSettings() contourDisplaySettings = ContourDisplaySettings() morphologicalOperationSettings = MorphologicalOperationSettings() matchTemplateSettings = MatchTemplateSettings() } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/ContourAnalysisView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.foundation.layout.* import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.domain.ContourDisplaySettings import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.domain.ContourFilterSettings import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.ContourAnalysisViewModel import cn.netdiscovery.monica.ui.widget.* import cn.netdiscovery.monica.utils.getValidateField import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.ErrorSeverity import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.ContourAnalysisView * @author: Tony Shen * @date: 2024/10/25 23:52 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) var contourFilterSettings:ContourFilterSettings = ContourFilterSettings() var contourDisplaySettings:ContourDisplaySettings = ContourDisplaySettings() @Composable fun contourAnalysis(state: ApplicationState, title: String) { val i18nState = rememberI18nState() val viewModel: ContourAnalysisViewModel = koinInject() var minPerimeterText by remember { mutableStateOf("") } var maxPerimeterText by remember { mutableStateOf("") } var minAreaText by remember { mutableStateOf("") } var maxAreaText by remember { mutableStateOf("") } var minRoundnessText by remember { mutableStateOf("") } var maxRoundnessText by remember { mutableStateOf("") } var minAspectRatioText by remember { mutableStateOf("") } var maxAspectRatioText by remember { mutableStateOf("") } fun clearContourPerimeterParams() { minPerimeterText = "" maxPerimeterText = "" contourFilterSettings.minPerimeter = 0.0 contourFilterSettings.maxPerimeter = 0.0 } fun clearContourAreaParams() { minAreaText = "" maxAreaText = "" contourFilterSettings.minArea = 0.0 contourFilterSettings.maxArea = 0.0 } fun clearContourRoundnessParams() { minRoundnessText = "" maxRoundnessText = "" contourFilterSettings.minRoundness = 0.0 contourFilterSettings.maxRoundness = 0.0 } fun clearContourAspectRatioParams() { minAspectRatioText = "" maxAspectRatioText = "" contourFilterSettings.minAspectRatio = 0.0 contourFilterSettings.maxAspectRatio = 0.0 } Column (modifier = Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 10.dp)) { title(modifier = Modifier.align(Alignment.CenterHorizontally) , text = title, color = Color.Black) Column{ subTitleWithDivider(text = i18nState.getString("filter_settings"), color = Color.Black) Row(verticalAlignment = Alignment.CenterVertically) { checkBoxWithTitle(i18nState.getString("perimeter"), Modifier.padding(end = 50.dp), checked = CVState.isContourPerimeter, onCheckedChange = { CVState.isContourPerimeter = it if (!CVState.isContourPerimeter) { clearContourPerimeterParams() } }) basicTextFieldWithTitle(titleText = i18nState.getString("min_value"), minPerimeterText) { str -> if (CVState.isContourPerimeter) { minPerimeterText = str contourFilterSettings.minPerimeter = getValidateField(block = { minPerimeterText.toDouble() } , failed = { val errorMsg = i18nState.getString("perimeter_min_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@basicTextFieldWithTitle } } basicTextFieldWithTitle(titleText = i18nState.getString("max_value"), maxPerimeterText) { str -> if (CVState.isContourPerimeter) { maxPerimeterText = str contourFilterSettings.maxPerimeter = getValidateField(block = { maxPerimeterText.toDouble() } , failed = { val errorMsg = i18nState.getString("perimeter_max_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@basicTextFieldWithTitle } } } Row(verticalAlignment = Alignment.CenterVertically) { checkBoxWithTitle(i18nState.getString("area"), Modifier.padding(end = 50.dp), checked = CVState.isContourArea, onCheckedChange = { CVState.isContourArea = it if (!CVState.isContourArea) { clearContourAreaParams() } }) basicTextFieldWithTitle(titleText = i18nState.getString("min_value"), minAreaText) { str -> if (CVState.isContourArea) { minAreaText = str contourFilterSettings.minArea = getValidateField(block = { minAreaText.toDouble() } , failed = { val errorMsg = i18nState.getString("area_min_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@basicTextFieldWithTitle } } basicTextFieldWithTitle(titleText = i18nState.getString("max_value"), maxAreaText) { str -> if (CVState.isContourArea) { maxAreaText = str contourFilterSettings.maxArea = getValidateField(block = { maxAreaText.toDouble() } , failed = { val errorMsg = i18nState.getString("area_max_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@basicTextFieldWithTitle } } } Row(verticalAlignment = Alignment.CenterVertically) { checkBoxWithTitle(i18nState.getString("roundness"), Modifier.padding(end = 50.dp), checked = CVState.isContourRoundness, onCheckedChange = { CVState.isContourRoundness = it if (!CVState.isContourRoundness) { clearContourRoundnessParams() } }) basicTextFieldWithTitle(titleText = i18nState.getString("min_value"), minRoundnessText) { str -> if (CVState.isContourRoundness) { minRoundnessText = str contourFilterSettings.minRoundness = getValidateField(block = { minRoundnessText.toDouble() } , failed = { val errorMsg = i18nState.getString("roundness_min_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@basicTextFieldWithTitle } } basicTextFieldWithTitle(titleText = i18nState.getString("max_value"), maxRoundnessText) { str -> if (CVState.isContourRoundness) { maxRoundnessText = str contourFilterSettings.maxRoundness = getValidateField(block = { maxRoundnessText.toDouble() } , failed = { val errorMsg = i18nState.getString("roundness_max_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@basicTextFieldWithTitle } } } Row(verticalAlignment = Alignment.CenterVertically) { checkBoxWithTitle(i18nState.getString("aspect_ratio"), Modifier.padding(end = 35.dp), checked = CVState.isContourAspectRatio, onCheckedChange = { CVState.isContourAspectRatio = it if (!CVState.isContourAspectRatio) { clearContourAspectRatioParams() } }) basicTextFieldWithTitle(titleText = i18nState.getString("min_value"), minAspectRatioText) { str -> if (CVState.isContourAspectRatio) { minAspectRatioText = str contourFilterSettings.minAspectRatio = getValidateField(block = { minAspectRatioText.toDouble() } , failed = { val errorMsg = i18nState.getString("aspect_ratio_min_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@basicTextFieldWithTitle } } basicTextFieldWithTitle(titleText = i18nState.getString("max_value"), maxAspectRatioText) { str -> if (CVState.isContourAspectRatio) { maxAspectRatioText = str contourFilterSettings.maxAspectRatio = getValidateField(block = { maxAspectRatioText.toDouble() } , failed = { val errorMsg = i18nState.getString("aspect_ratio_max_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@basicTextFieldWithTitle } } } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("contour_display_settings"), color = Color.Black) Row(verticalAlignment = Alignment.CenterVertically) { checkBoxWithTitle(i18nState.getString("show_original_image"), Modifier.padding(end = 50.dp), checked = CVState.showOriginalImage, onCheckedChange = { contourDisplaySettings.showOriginalImage = it CVState.showOriginalImage = it }) checkBoxWithTitle(i18nState.getString("show_bounding_rect"), Modifier.padding(end = 50.dp), checked = CVState.showBoundingRect, onCheckedChange = { contourDisplaySettings.showBoundingRect = it CVState.showBoundingRect = it }) checkBoxWithTitle(i18nState.getString("show_min_area_rect"),Modifier.padding(end = 50.dp), checked = CVState.showMinAreaRect, onCheckedChange = { contourDisplaySettings.showMinAreaRect = it CVState.showMinAreaRect = it }) checkBoxWithTitle(i18nState.getString("show_center"),Modifier.padding(end = 50.dp), checked = CVState.showCenter, onCheckedChange = { contourDisplaySettings.showCenter = it CVState.showCenter = it }) } } Button( modifier = Modifier.padding(top = 10.dp).align(Alignment.End), onClick = experimentViewClick(state) { if(state.currentImage?.type == BufferedImage.TYPE_BYTE_BINARY) { if (CVState.isContourPerimeter) { if (contourFilterSettings.minPerimeter == 0.0 && contourFilterSettings.maxPerimeter == 0.0) { val errorMsg = i18nState.getString("perimeter_at_least_one_value") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) return@experimentViewClick } } if (CVState.isContourArea) { if (contourFilterSettings.minArea == 0.0 && contourFilterSettings.maxArea == 0.0) { val errorMsg = i18nState.getString("area_at_least_one_value") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) return@experimentViewClick } } if (CVState.isContourRoundness) { if (contourFilterSettings.minRoundness == 0.0 && contourFilterSettings.maxRoundness == 0.0) { val errorMsg = i18nState.getString("roundness_at_least_one_value") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) return@experimentViewClick } } if (CVState.isContourAspectRatio) { if (contourFilterSettings.minAspectRatio == 0.0 && contourFilterSettings.maxAspectRatio == 0.0) { val errorMsg = i18nState.getString("aspect_ratio_at_least_one_value") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) return@experimentViewClick } } viewModel.contourAnalysis(state, contourFilterSettings, contourDisplaySettings) } else { val errorMsg = i18nState.getString("please_binarize_image_first_for_contour") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) } } ) { Text(text = i18nState.getString("contour_analysis"), color = Color.Unspecified) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/EdgeDetectionView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.foundation.layout.* import androidx.compose.material.Button import androidx.compose.material.Checkbox import androidx.compose.material.RadioButton import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.EdgeDetectionViewModel import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.i18n.LocalizationManager import cn.netdiscovery.monica.ui.widget.* import cn.netdiscovery.monica.utils.getValidateField import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.ErrorSeverity import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.EdgeDetectionView * @author: Tony Shen * @date: 2024/10/13 22:17 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) val firstDerivativeOperatorTags = arrayListOf(LocalizationManager.getString("roberts_operator"), LocalizationManager.getString("prewitt_operator"), LocalizationManager.getString("sobel_operator")) val secondDerivativeOperatorTags = arrayListOf(LocalizationManager.getString("laplace_operator"), LocalizationManager.getString("log_operator"), LocalizationManager.getString("dog_operator")) @Composable fun edgeDetection(state: ApplicationState, title: String) { val i18nState = rememberI18nState() val viewModel: EdgeDetectionViewModel = koinInject() var firstDerivativeOperatorSelectedOption by remember { mutableStateOf("Null") } var secondDerivativeOperatorSelectedOption by remember { mutableStateOf("Null") } var threshold1Text by remember { mutableStateOf("") } var threshold2Text by remember { mutableStateOf("") } var apertureSizeText by remember { mutableStateOf("3") } var sigma1Text by remember { mutableStateOf("") } var sigma2Text by remember { mutableStateOf("") } var sizeText by remember { mutableStateOf("") } fun clearCannyParams() { threshold1Text = "" threshold2Text = "" apertureSizeText = "3" } fun clearDoGParams() { sigma1Text = "" sigma2Text = "" sizeText = "" } Column (modifier = Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 10.dp)) { title(modifier = Modifier.align(Alignment.CenterHorizontally) , text = title, color = Color.Black) Column{ subTitleWithDivider(text = i18nState.getString("edge_detection_operator"), color = Color.Black) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(CVState.isFirstDerivativeOperator, onCheckedChange = { CVState.isFirstDerivativeOperator = it if (!CVState.isFirstDerivativeOperator) { firstDerivativeOperatorSelectedOption = "Null" } else { CVState.isSecondDerivativeOperator = false CVState.isCannyOperator = false clearCannyParams() clearDoGParams() } }) Text(i18nState.getString("first_derivative_operator"), modifier = Modifier.align(Alignment.CenterVertically)) } Row { firstDerivativeOperatorTags.forEach { RadioButton( selected = (CVState.isFirstDerivativeOperator && it == firstDerivativeOperatorSelectedOption), onClick = { firstDerivativeOperatorSelectedOption = it } ) Text(text = it, modifier = Modifier.align(Alignment.CenterVertically)) } Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Center) { Button( onClick = experimentViewClick(state) { if (firstDerivativeOperatorSelectedOption == "Null") { val errorMsg = i18nState.getString("please_select_first_derivative_operator") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) return@experimentViewClick } when(firstDerivativeOperatorSelectedOption) { firstDerivativeOperatorTags[0] -> viewModel.roberts(state) firstDerivativeOperatorTags[1] -> viewModel.prewitt(state) firstDerivativeOperatorTags[2] -> viewModel.sobel(state) else -> {} } } ) { Text(text = i18nState.getString("first_derivative_edge_detection"), color = Color.Unspecified) } } } Row(modifier = Modifier.padding(top = 10.dp),verticalAlignment = Alignment.CenterVertically) { Checkbox(CVState.isSecondDerivativeOperator, onCheckedChange = { CVState.isSecondDerivativeOperator = it if (!CVState.isSecondDerivativeOperator) { secondDerivativeOperatorSelectedOption = "Null" } else { CVState.isFirstDerivativeOperator = false CVState.isCannyOperator = false clearCannyParams() } }) Text(i18nState.getString("second_derivative_operator"), modifier = Modifier.align(Alignment.CenterVertically)) } Row { secondDerivativeOperatorTags.forEach { RadioButton( selected = (CVState.isSecondDerivativeOperator && it == secondDerivativeOperatorSelectedOption), onClick = { secondDerivativeOperatorSelectedOption = it } ) Text(text = it, modifier = Modifier.align(Alignment.CenterVertically)) } Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Center) { Button( onClick = experimentViewClick(state) { if (secondDerivativeOperatorSelectedOption == "Null") { val errorMsg = i18nState.getString("please_select_second_derivative_operator") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) return@experimentViewClick } when(secondDerivativeOperatorSelectedOption) { secondDerivativeOperatorTags[0] -> viewModel.laplace(state) secondDerivativeOperatorTags[1] -> viewModel.log(state) secondDerivativeOperatorTags[2] -> { val sigma1 = getValidateField(block = { sigma1Text.toDouble() } , failed = { val errorMsg = i18nState.getString("sigma1_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val sigma2 = getValidateField(block = { sigma2Text.toDouble() } , failed = { val errorMsg = i18nState.getString("sigma2_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val size = getValidateField(block = { sizeText.toInt() } , failed = { val errorMsg = i18nState.getString("size_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick viewModel.dog(state, sigma1, sigma2, size) } else -> {} } } ) { Text(text = i18nState.getString("second_derivative_edge_detection"), color = Color.Unspecified) } } } if (CVState.isSecondDerivativeOperator && secondDerivativeOperatorSelectedOption == secondDerivativeOperatorTags[2]) { Row { basicTextFieldWithTitle(titleText = "sigma1", sigma1Text) { str -> if (CVState.isSecondDerivativeOperator) { sigma1Text = str } } basicTextFieldWithTitle(titleText = "sigma2", sigma2Text) { str -> if (CVState.isSecondDerivativeOperator) { sigma2Text = str } } basicTextFieldWithTitle(titleText = "size", sizeText) { str -> if (CVState.isSecondDerivativeOperator) { sizeText = str } } } } Row(modifier = Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) { Checkbox(CVState.isCannyOperator, onCheckedChange = { CVState.isCannyOperator = it if (!CVState.isCannyOperator) { clearCannyParams() } else { CVState.isFirstDerivativeOperator = false CVState.isSecondDerivativeOperator = false clearDoGParams() } }) Text(i18nState.getString("canny_operator"), modifier = Modifier.align(Alignment.CenterVertically)) } Row(modifier = Modifier.padding(top = 10.dp)) { basicTextFieldWithTitle(titleText = "threshold1", threshold1Text) { str -> threshold1Text = str } basicTextFieldWithTitle(titleText = "threshold2", threshold2Text) { str -> threshold2Text = str } basicTextFieldWithTitle(titleText = "apertureSize", apertureSizeText) { str -> apertureSizeText = str } } Button( modifier = Modifier.padding(top = 10.dp).align(Alignment.End), onClick = experimentViewClick(state) { if(state.currentImage?.type != BufferedImage.TYPE_BYTE_BINARY) { val threshold1 = getValidateField(block = { threshold1Text.toDouble() }, failed = { val errorMsg = i18nState.getString("threshold1_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val threshold2 = getValidateField(block = { threshold2Text.toDouble() }, failed = { val errorMsg = i18nState.getString("threshold2_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val apertureSize = getValidateField(block = { apertureSizeText.toInt() }, failed = { val errorMsg = i18nState.getString("aperture_size_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick viewModel.canny(state, threshold1, threshold2, apertureSize) } } ) { Text(text = i18nState.getString("canny_edge_detection_full"), color = Color.Unspecified) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/ExperimentHome.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.ui.widget.subTitle import cn.netdiscovery.monica.ui.i18n.rememberI18nState /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.ExperimentHome * @author: Tony Shen * @date: 2024/11/2 00:27 * @version: V1.0 <描述当前版本功能> */ @Composable fun experimentHome() { val i18nState = rememberI18nState() Column (modifier = Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 10.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Image(painter = painterResource("images/ai/OpenCV_Logo.png"), contentDescription = null, modifier = Modifier) subTitle(modifier = Modifier.padding(top = 20.dp), text = i18nState.getString("experiment_home_description")) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/ExperimentView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.toPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.widget.* import cn.netdiscovery.monica.utils.Action import cn.netdiscovery.monica.utils.chooseImage import cn.netdiscovery.monica.utils.getBufferedImage import loadingDisplay import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.i18n.LocalizationManager import cn.netdiscovery.monica.exception.ErrorHandler import cn.netdiscovery.monica.exception.ErrorState import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.ExperimentView * @author: Tony Shen * @date: 2024/9/23 19:37 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) /** * Screens */ enum class Screen( private val labelKey: String, val resourcePath: String ) { Home( labelKey = "experiment_home", resourcePath = "images/ai/home.png" ), BinaryImage( labelKey = "experiment_binary_image", resourcePath = "images/ai/binary_image.png" ), EdgeDetection( labelKey = "experiment_edge_detection", resourcePath = "images/ai/edge_detection.png" ), ContourAnalysis( labelKey = "experiment_contour_analysis", resourcePath = "images/ai/contour_analysis.png" ), ImageEnhance( labelKey = "experiment_image_enhance", resourcePath = "images/ai/image_enhance.png" ), ImageDenoising( labelKey = "experiment_image_denoising", resourcePath = "images/ai/image_convolution.png" ), MorphologicalOperations( labelKey = "experiment_morphological_operations", resourcePath = "images/ai/morphological_operations.png" ), MatchTemplate( labelKey = "experiment_match_template", resourcePath = "images/ai/match_template.png" ), History( labelKey = "experiment_history", resourcePath = "images/ai/history.png" ); fun getLabel(): String { return LocalizationManager.getString(labelKey) } } @Composable fun customNavigationHost( state: ApplicationState, navController: NavController ) { NavigationHost(navController) { composable(Screen.Home.name) { experimentHome() } composable(Screen.BinaryImage.name) { binaryImage(state, Screen.BinaryImage.getLabel()) } composable(Screen.EdgeDetection.name) { edgeDetection(state, Screen.EdgeDetection.getLabel()) } composable(Screen.ContourAnalysis.name) { contourAnalysis(state, Screen.ContourAnalysis.getLabel()) } composable(Screen.ImageEnhance.name) { imageEnhance(state, Screen.ImageEnhance.getLabel()) } composable(Screen.ImageDenoising.name) { imageDenoising(state, Screen.ImageDenoising.getLabel()) } composable(Screen.MorphologicalOperations.name) { morphologicalOperations(state, Screen.MorphologicalOperations.getLabel()) } composable(Screen.MatchTemplate.name) { matchTemplate(state, Screen.MatchTemplate.getLabel()) } composable(Screen.History.name) { history(state, Screen.History.getLabel()) } }.build() } @OptIn(ExperimentalMaterialApi::class) @Composable fun experiment(state: ApplicationState) { val i18nState = rememberI18nState() // 页面级别的错误处理状态 val errorState = remember { ErrorState() } val screens = Screen.entries val navController by rememberNavController(Screen.Home.name) val currentScreen by remember { navController.currentScreen } PageLifecycle( onInit = { logger.info("OpenCVDebugView 启动时初始化") }, onDisposeEffect = { logger.info("OpenCVDebugView 关闭时释放资源") CVState.clearAllStatus() } ) Box( modifier = Modifier .fillMaxSize() .background( brush = Brush.verticalGradient( colors = listOf( MaterialTheme.colors.background, MaterialTheme.colors.surface ) ) ), contentAlignment = Alignment.Center ) { Row ( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { NavigationRail( modifier = Modifier.fillMaxHeight().width(100.dp).weight(0.5f) ) { screens.forEach { NavigationRailItem( selected = currentScreen == it.name, icon = { Icon( painter = painterResource(it.resourcePath), modifier = Modifier.width(25.dp).height(25.dp), contentDescription = it.getLabel() ) }, label = { Text(it.getLabel()) }, modifier = Modifier.width(100.dp).height(80.dp), alwaysShowLabel = true, onClick = { navController.navigate(it.name) } ) } } Box( Modifier.fillMaxSize().weight(9.5f), contentAlignment = Alignment.Center ) { Row (modifier = Modifier.fillMaxSize().padding(end = 90.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { Column (modifier = Modifier.fillMaxSize().weight(1.0f), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { customNavigationHost(state, navController) } Card( modifier = Modifier.padding(10.dp).weight(1.0f), shape = RoundedCornerShape(8.dp), elevation = 4.dp, onClick = { chooseImage(state) { file -> state.rawImage = getBufferedImage(file, state) state.currentImage = state.rawImage state.rawImageFile = file } }, enabled = state.currentImage == null ) { if (state.currentImage == null) { Text( modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center), text = i18nState.getString("click_to_select_image"), textAlign = TextAlign.Center ) } else { Image( painter = state.currentImage!!.toPainter(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier ) } } } rightSideMenuBar(modifier = Modifier.align(Alignment.CenterEnd)) { toolTipButton(text = i18nState.getString("delete"), painter = painterResource("images/preview/delete.png"), iconModifier = Modifier.size(36.dp), onClick = { state.clearImage() }) toolTipButton(text = i18nState.getString("undo"), painter = painterResource("images/doodle/previous_step.png"), iconModifier = Modifier.size(36.dp), onClick = { state.getLastImage()?.let { state.currentImage = it } }) toolTipButton(text = i18nState.getString("save"), painter = painterResource("images/doodle/save.png"), iconModifier = Modifier.size(36.dp), onClick = { state.togglePreviewWindow(false) }) } } } if (loadingDisplay) { showLoading() } // 页面级别的错误处理 - 在最后渲染,确保在最顶层 ErrorHandler(errorState) } } @Composable fun experimentViewClick( state: ApplicationState, onClick: Action ): Action { val i18nState = rememberI18nState() return rememberThrottledClick(filter = { if (state.currentImage == null) { val errorMsg = i18nState.getString("please_select_image_first") cn.netdiscovery.monica.exception.showError( type = cn.netdiscovery.monica.exception.ErrorType.VALIDATION_ERROR, severity = cn.netdiscovery.monica.exception.ErrorSeverity.MEDIUM, message = errorMsg, userMessage = errorMsg ) false } else { true } }, onClick = onClick) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/HistoryView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.foundation.lazy.* import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.history.HistoryEntry import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.HistoryViewModel import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.ui.widget.divider import cn.netdiscovery.monica.ui.widget.title import cn.netdiscovery.monica.utils.formatTimestamp import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.Date /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.HistoryView * @author: Tony Shen * @date: 2025/7/30 09:32 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) @Composable fun history(state: ApplicationState, title: String) { val i18nState = rememberI18nState() val viewModel: HistoryViewModel = koinInject() val historyEntries = remember { mutableStateListOf() } LaunchedEffect(Unit) { historyEntries.clear() historyEntries.addAll(viewModel.getOperationLog()) } Column (modifier = Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp)) { title(modifier = Modifier.align(Alignment.CenterHorizontally), text = title, color = Color.Black) CVHistoryList(historyEntries, i18nState) } } @Composable fun CVHistoryList(history: List, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { LazyColumn(modifier = Modifier.fillMaxSize()) { items(history) { entry -> HistoryItem(entry, i18nState) divider() } } } } @Composable fun HistoryItem(entry: HistoryEntry, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState) { Column(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) { Text( text = "${i18nState.getString("operation")}: ${entry.operation}", ) Text( text = "${i18nState.getString("time")}: ${formatTimestamp.format(Date(entry.timestamp))}", ) Text( text = "${i18nState.getString("parameters")}: ${entry.parameters.entries.joinToString { "${it.key}=${it.value}" }}", maxLines = 6, overflow = TextOverflow.Ellipsis ) if (entry.description.isNotEmpty()) { Text( text = "${i18nState.getString("description")}: ${entry.description}" ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/ImageDenoisingView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.ImageDenoisingViewModel import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.ui.widget.basicTextFieldWithTitle import cn.netdiscovery.monica.ui.widget.subTitleWithDivider import cn.netdiscovery.monica.ui.widget.title import cn.netdiscovery.monica.utils.getValidateField import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.ErrorSeverity import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.ImageDenoisingView * @author: Tony Shen * @date: 2024/12/4 14:17 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) @Composable fun imageDenoising(state: ApplicationState, title: String) { val i18nState = rememberI18nState() val viewModel: ImageDenoisingViewModel = koinInject() var gaussianBlurKSizeText by remember { mutableStateOf("") } var sigmaXText by remember { mutableStateOf("0.0") } var sigmaYText by remember { mutableStateOf("0.0") } var medianBlurKSizeText by remember { mutableStateOf("") } var dText by remember { mutableStateOf("") } var sigmaColorText by remember { mutableStateOf("") } var sigmaSpaceText by remember { mutableStateOf("") } var spText by remember { mutableStateOf("") } var srText by remember { mutableStateOf("") } Column (modifier = Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 10.dp)) { title(modifier = Modifier.align(Alignment.CenterHorizontally), text = title, color = Color.Black) Column { subTitleWithDivider(text = i18nState.getString("gaussian_filter"), color = Color.Black) Row(modifier = Modifier.padding(top = 10.dp)) { basicTextFieldWithTitle(titleText = "ksize", gaussianBlurKSizeText) { str -> gaussianBlurKSizeText = str } basicTextFieldWithTitle(titleText = "sigmaX", sigmaXText) { str -> sigmaXText = str } basicTextFieldWithTitle(titleText = "sigmaY", sigmaYText) { str -> sigmaYText = str } } Button( modifier = Modifier.align(Alignment.End), onClick = experimentViewClick(state) { val ksize = getValidateField(block = { gaussianBlurKSizeText.toInt() } , failed = { val errorMsg = i18nState.getString("ksize_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val sigmaX = getValidateField(block = { sigmaXText.toDouble() } , failed = { val errorMsg = i18nState.getString("sigma_x_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val sigmaY = getValidateField(block = { sigmaYText.toDouble() } , failed = { val errorMsg = i18nState.getString("sigma_y_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick viewModel.gaussianBlur(state, ksize, sigmaX, sigmaY) } ) { Text(text = i18nState.getString("gaussian_filter"), color = Color.Unspecified) } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("median_filter"), color = Color.Black) Row(modifier = Modifier.padding(top = 10.dp)) { basicTextFieldWithTitle(titleText = "ksize", medianBlurKSizeText) { str -> medianBlurKSizeText = str } } Button( modifier = Modifier.align(Alignment.End), onClick = experimentViewClick(state) { val ksize = getValidateField(block = { medianBlurKSizeText.toInt() } , failed = { val errorMsg = i18nState.getString("ksize_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick viewModel.medianBlur(state, ksize) } ) { Text(text = i18nState.getString("median_filter"), color = Color.Unspecified) } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("gaussian_bilateral_filter"), color = Color.Black) Row(modifier = Modifier.padding(top = 10.dp)) { basicTextFieldWithTitle(titleText = "d", dText) { str -> dText = str } basicTextFieldWithTitle(titleText = "sigmaColor", sigmaColorText) { str -> sigmaColorText = str } basicTextFieldWithTitle(titleText = "sigmaSpace", sigmaSpaceText) { str -> sigmaSpaceText = str } } Button( modifier = Modifier.align(Alignment.End), onClick = experimentViewClick(state) { val d = getValidateField(block = { dText.toInt() } , failed = { val errorMsg = i18nState.getString("d_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val sigmaColor = getValidateField(block = { sigmaColorText.toDouble() } , failed = { val errorMsg = i18nState.getString("sigma_color_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val sigmaSpace = getValidateField(block = { sigmaSpaceText.toDouble() } , failed = { val errorMsg = i18nState.getString("sigma_space_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick viewModel.bilateralFilter(state, d, sigmaColor, sigmaSpace) } ) { Text(text = i18nState.getString("gaussian_bilateral_filter"), color = Color.Unspecified) } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("mean_shift_filter"), color = Color.Black) Row(modifier = Modifier.padding(top = 10.dp)) { basicTextFieldWithTitle(titleText = "sp", spText) { str -> spText = str } basicTextFieldWithTitle(titleText = "sr", srText) { str -> srText = str } } Button( modifier = Modifier.align(Alignment.End), onClick = experimentViewClick(state) { val sp = getValidateField(block = { spText.toDouble() } , failed = { val errorMsg = i18nState.getString("sp_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val sr = getValidateField(block = { srText.toDouble() } , failed = { val errorMsg = i18nState.getString("sr_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick viewModel.pyrMeanShiftFiltering(state, sp, sr) } ) { Text(text = i18nState.getString("mean_shift_filter"), color = Color.Unspecified) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/ImageEnhanceView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.ImageEnhanceViewModel import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.ui.widget.basicTextFieldWithTitle import cn.netdiscovery.monica.ui.widget.subTitleWithDivider import cn.netdiscovery.monica.ui.widget.title import cn.netdiscovery.monica.utils.getValidateField import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.ErrorSeverity import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.ImageEnhanceView * @author: Tony Shen * @date: 2024/12/3 19:44 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) @Composable fun imageEnhance(state: ApplicationState, title: String) { val i18nState = rememberI18nState() val viewModel: ImageEnhanceViewModel = koinInject() var clipLimitText by remember { mutableStateOf("4") } var sizeText by remember { mutableStateOf("10") } var gammaText by remember { mutableStateOf("1.0") } var amountText by remember { mutableStateOf("25") } var thresholdText by remember { mutableStateOf("0") } var radiusText by remember { mutableStateOf("50") } var ratioText by remember { mutableStateOf("4") } var aceRadiusText by remember { mutableStateOf("1") } Column (modifier = Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 10.dp)) { title(modifier = Modifier.align(Alignment.CenterHorizontally) , text = title, color = Color.Black) Column { subTitleWithDivider(text = i18nState.getString("histogram_equalization"), color = Color.Black) Button( modifier = Modifier.align(Alignment.End), onClick = experimentViewClick(state) { viewModel.equalizeHist(state) } ) { Text(text = i18nState.getString("histogram_equalization_button"), color = Color.Unspecified) } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("clahe"), color = Color.Black) Row(modifier = Modifier.padding(top = 10.dp)) { basicTextFieldWithTitle(titleText = "clipLimit", clipLimitText) { str -> clipLimitText = str } basicTextFieldWithTitle(titleText = "size", sizeText) { str -> sizeText = str } } Button( modifier = Modifier.align(Alignment.End), onClick = experimentViewClick(state) { val clipLimit = getValidateField(block = { clipLimitText.toDouble() } , failed = { val errorMsg = i18nState.getString("clip_limit_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val size = getValidateField(block = { sizeText.toInt() } , failed = { val errorMsg = i18nState.getString("size_needs_int_for_enhance") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick viewModel.clahe(state, clipLimit, size) } ) { Text(text = i18nState.getString("clahe_button"), color = Color.Unspecified) } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("gamma_transform"), color = Color.Black) Row(modifier = Modifier.padding(top = 10.dp)) { basicTextFieldWithTitle(titleText = "gamma", gammaText) { str -> gammaText = str } } Button( modifier = Modifier.align(Alignment.End), onClick = experimentViewClick(state) { val gamma = getValidateField(block = { gammaText.toFloat() } , failed = { val errorMsg = i18nState.getString("gamma_needs_float") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick viewModel.gammaCorrection(state, gamma) } ) { Text(text = i18nState.getString("gamma_transform_button"), color = Color.Unspecified) } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("laplace_sharpening"), color = Color.Black) Button( modifier = Modifier.align(Alignment.End), onClick = experimentViewClick(state) { viewModel.laplaceSharpening(state) } ) { Text(text = i18nState.getString("laplace_sharpen_button"), color = Color.Unspecified) } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("usm_sharpening"), color = Color.Black) Row(modifier = Modifier.padding(top = 10.dp)) { basicTextFieldWithTitle(titleText = "Radius", radiusText) { str -> radiusText = str } basicTextFieldWithTitle(titleText = "Threshold", thresholdText) { str -> thresholdText = str } basicTextFieldWithTitle(titleText = "Amount", amountText) { str -> amountText = str } } Button( modifier = Modifier.align(Alignment.End), onClick = experimentViewClick(state) { val radius = getValidateField(block = { radiusText.toInt() } , failed = { val errorMsg = i18nState.getString("radius_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val threshold = getValidateField(block = { thresholdText.toInt() } , failed = { val errorMsg = i18nState.getString("threshold_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val amount = getValidateField(block = { amountText.toInt() } , failed = { val errorMsg = i18nState.getString("amount_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick viewModel.unsharpMask(state, radius, threshold, amount) } ) { Text(text = i18nState.getString("usm_sharpen_button"), color = Color.Unspecified) } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("automatic_color_balance"), color = Color.Black) Row(modifier = Modifier.padding(top = 10.dp)) { basicTextFieldWithTitle(titleText = "Ratio", ratioText) { str -> ratioText = str } basicTextFieldWithTitle(titleText = "Radius", aceRadiusText) { str -> aceRadiusText = str } } Button( modifier = Modifier.align(Alignment.End), onClick = experimentViewClick(state) { val ratio = getValidateField(block = { ratioText.toInt() } , failed = { val errorMsg = i18nState.getString("ratio_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val radius = getValidateField(block = { aceRadiusText.toInt() } , failed = { val errorMsg = i18nState.getString("radius_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick viewModel.ace(state, ratio, radius) } ) { Text(text = i18nState.getString("auto_color_balance_button"), color = Color.Unspecified) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/MatchTemplateView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.imageprocess.BufferedImages import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.domain.MatchTemplateSettings import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.i18n.LocalizationManager import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.MatchTemplateViewModel import cn.netdiscovery.monica.ui.widget.basicTextFieldWithTitle import cn.netdiscovery.monica.ui.widget.subTitleWithDivider import cn.netdiscovery.monica.ui.widget.title import cn.netdiscovery.monica.ui.widget.toolTipButton import cn.netdiscovery.monica.utils.chooseImage import cn.netdiscovery.monica.utils.getBufferedImage import cn.netdiscovery.monica.utils.getValidateField import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.ErrorSeverity import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.MatchTemplateView * @author: Tony Shen * @date: 2024/12/29 14:03 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) val matchingMethodTag = arrayListOf( LocalizationManager.getString("original_image_matching"), LocalizationManager.getString("grayscale_matching"), LocalizationManager.getString("edge_matching") ) var matchTemplateSettings: MatchTemplateSettings = MatchTemplateSettings() @OptIn(ExperimentalMaterialApi::class) @Composable fun matchTemplate(state: ApplicationState, title: String) { val i18nState = rememberI18nState() val viewModel: MatchTemplateViewModel = koinInject() var matchingMethodOption by remember { mutableStateOf(LocalizationManager.getString("original_image_matching")) } var angleStartText by remember { mutableStateOf("0") } var angleEndText by remember { mutableStateOf("360") } var angleStepText by remember { mutableStateOf("10") } var scaleStartText by remember { mutableStateOf("0.1") } var scaleEndText by remember { mutableStateOf("1.0") } var scaleStepText by remember { mutableStateOf("0.1") } var matchTemplateThresholdText by remember { mutableStateOf("0.8") } var scoreThresholdText by remember { mutableStateOf("0.6") } var nmsThresholdText by remember { mutableStateOf("0.3") } Column (modifier = Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 10.dp)) { title(modifier = Modifier.align(Alignment.CenterHorizontally), text = title, color = Color.Black) Column { subTitleWithDivider(text = i18nState.getString("template"), color = Color.Black) Row { Text(modifier = Modifier.width(100.dp).padding(top = 10.dp), text = i18nState.getString("import_template"), color = Color.Unspecified) Card( modifier = Modifier.padding(10.dp).width(150.dp).height(150.dp), shape = RoundedCornerShape(8.dp), elevation = 4.dp, onClick = { chooseImage(state) { file -> CVState.templateImage = getBufferedImage(file) } }, enabled = CVState.templateImage == null ) { if (CVState.templateImage == null) { Text( modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center), text = i18nState.getString("click_to_select_image"), textAlign = TextAlign.Center ) } else { Box { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Image( painter = CVState.templateImage!!.toPainter(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier) } Row(modifier = Modifier.align(Alignment.TopEnd)) { toolTipButton(text = i18nState.getString("delete_source_image"), painter = painterResource("images/preview/delete.png"), buttonModifier = Modifier, iconModifier = Modifier.size(20.dp), onClick = { viewModel.clearTemplateImage() }) } } } } } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("matching_method"), color = Color.Black) Row { matchingMethodTag.forEach { RadioButton( selected = (it == matchingMethodOption), onClick = { matchingMethodOption = it val index = matchingMethodTag.indexOf(it) matchTemplateSettings = matchTemplateSettings.copy(matchType = index) } ) Text(text = it, modifier = Modifier.width(120.dp).align(Alignment.CenterVertically)) } } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("rotation"), color = Color.Black) Row(modifier = Modifier.padding(top = 20.dp, bottom = 20.dp)) { basicTextFieldWithTitle(titleText = i18nState.getString("min_angle"), angleStartText) { str -> angleStartText = str } basicTextFieldWithTitle(titleText = i18nState.getString("max_angle"), angleEndText) { str -> angleEndText = str } basicTextFieldWithTitle(titleText = i18nState.getString("angle_step"), angleStepText) { str -> angleStepText = str } } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("scale"), color = Color.Black) Row(modifier = Modifier.padding(top = 20.dp, bottom = 20.dp)) { basicTextFieldWithTitle(titleText = i18nState.getString("min_scale"), scaleStartText) { str -> scaleStartText = str } basicTextFieldWithTitle(titleText = i18nState.getString("max_scale"), scaleEndText) { str -> scaleEndText = str } basicTextFieldWithTitle(titleText = i18nState.getString("scale_step"), scaleStepText) { str -> scaleStepText = str } } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("template_matching_params"), color = Color.Black) Row(modifier = Modifier.padding(top = 20.dp, bottom = 20.dp)) { basicTextFieldWithTitle(titleText = i18nState.getString("threshold"), matchTemplateThresholdText) { str -> matchTemplateThresholdText = str } } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("nms_params"), color = Color.Black) Row(modifier = Modifier.padding(top = 20.dp)) { basicTextFieldWithTitle(titleText = i18nState.getString("score_threshold"), scoreThresholdText) { str -> scoreThresholdText = str } basicTextFieldWithTitle(titleText = i18nState.getString("nms_threshold"), nmsThresholdText) { str -> nmsThresholdText = str } } } Button( modifier = Modifier.padding(top = 10.dp).align(Alignment.End), onClick = experimentViewClick(state) { if (CVState.templateImage == null) { val errorMsg = i18nState.getString("please_import_template_first") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) return@experimentViewClick } val angleStart = getValidateField(block = { angleStartText.toInt() } , condition = { it in 0..360 }, failed = { val errorMsg = i18nState.getString("angle_start_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val angleEnd = getValidateField(block = { angleEndText.toInt() } , condition = { it in 0..360 }, failed = { val errorMsg = i18nState.getString("angle_end_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val angleStep = getValidateField(block = { angleStepText.toInt() } , condition = { it > 0 }, failed = { val errorMsg = i18nState.getString("angle_step_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val scaleStart = getValidateField(block = { scaleStartText.toDouble() } , condition = { it in 0.0..1.0 }, failed = { val errorMsg = i18nState.getString("scale_start_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val scaleEnd = getValidateField(block = { scaleEndText.toDouble() } , condition = { it in 0.0..1.0 }, failed = { val errorMsg = i18nState.getString("scale_end_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val scaleStep = getValidateField(block = { scaleStepText.toDouble() } , condition = { it > 0 }, failed = { val errorMsg = i18nState.getString("scale_step_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val matchTemplateThreshold = getValidateField(block = { matchTemplateThresholdText.toDouble() } , condition = { it in 0.0..1.0 }, failed = { val errorMsg = i18nState.getString("match_template_threshold_needs_double") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val scoreThreshold = getValidateField(block = { scoreThresholdText.toFloat() } , condition = { it in 0.0..1.0 }, failed = { val errorMsg = i18nState.getString("score_threshold_needs_float") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val nmsThreshold = getValidateField(block = { nmsThresholdText.toFloat() } , condition = { it in 0.0..1.0 }, failed = { val errorMsg = i18nState.getString("nms_threshold_needs_float") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick matchTemplateSettings = matchTemplateSettings.copy(angleStart = angleStart, angleEnd = angleEnd, angleStep = angleStep, scaleStart = scaleStart, scaleEnd = scaleEnd, scaleStep = scaleStep, matchTemplateThreshold = matchTemplateThreshold, scoreThreshold = scoreThreshold, nmsThreshold = nmsThreshold) viewModel.matchTemplate(state, matchTemplateSettings) } ) { Text(text = i18nState.getString("template_matching"), color = Color.Unspecified) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/MorphologicalOperationsView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.foundation.layout.* import androidx.compose.material.Button import androidx.compose.material.RadioButton import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.domain.MorphologicalOperationSettings import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.i18n.LocalizationManager import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.MorphologicalOperationsViewModel import cn.netdiscovery.monica.ui.widget.basicTextFieldWithTitle import cn.netdiscovery.monica.ui.widget.subTitleWithDivider import cn.netdiscovery.monica.ui.widget.title import cn.netdiscovery.monica.utils.getValidateField import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.ErrorSeverity import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.model.MorphologicalOperationsView * @author: Tony Shen * @date: 2024/12/21 20:16 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) val operatingElementsTag = arrayListOf( LocalizationManager.getString("erosion"), LocalizationManager.getString("dilation"), LocalizationManager.getString("opening"), LocalizationManager.getString("closing"), LocalizationManager.getString("morphological_gradient"), LocalizationManager.getString("top_hat"), LocalizationManager.getString("black_hat"), LocalizationManager.getString("hit_miss") ) val structuralElementsTag = arrayListOf( LocalizationManager.getString("rectangle"), LocalizationManager.getString("cross"), LocalizationManager.getString("ellipse") ) val tagList1 = operatingElementsTag.take(4) val tagList2 = operatingElementsTag.takeLast(4) var morphologicalOperationSettings: MorphologicalOperationSettings = MorphologicalOperationSettings() @Composable fun morphologicalOperations(state: ApplicationState, title: String) { val i18nState = rememberI18nState() val viewModel: MorphologicalOperationsViewModel = koinInject() var operatingElementOption by remember { mutableStateOf("Null") } var structuralElementOption by remember { mutableStateOf(LocalizationManager.getString("rectangle")) } var widthText by remember { mutableStateOf("3") } var heightText by remember { mutableStateOf("3") } Column (modifier = Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 10.dp)) { title(modifier = Modifier.align(Alignment.CenterHorizontally), text = title, color = Color.Black) Column { subTitleWithDivider(text = i18nState.getString("operation_element"), color = Color.Black) Row { tagList1.forEach { RadioButton( selected = (it == operatingElementOption), onClick = { operatingElementOption = it val index = operatingElementsTag.indexOf(it) morphologicalOperationSettings = morphologicalOperationSettings.copy(op = index) } ) Text(text = it, modifier = Modifier.width(120.dp).align(Alignment.CenterVertically)) } } Row { tagList2.forEach { RadioButton( selected = (it == operatingElementOption), onClick = { operatingElementOption = it val index = operatingElementsTag.indexOf(it) morphologicalOperationSettings = morphologicalOperationSettings.copy(op = index) } ) Text(text = it, modifier = Modifier.width(120.dp).align(Alignment.CenterVertically)) } } } Column(modifier = Modifier.padding(top = 20.dp)) { subTitleWithDivider(text = i18nState.getString("structural_element"), color = Color.Black) Row { structuralElementsTag.forEach { RadioButton( selected = (it == structuralElementOption), onClick = { structuralElementOption = it val index = structuralElementsTag.indexOf(it) morphologicalOperationSettings = morphologicalOperationSettings.copy(shape = index) } ) Text(text = it, modifier = Modifier.width(120.dp).align(Alignment.CenterVertically)) } } Row(modifier = Modifier.padding(top = 20.dp)) { Text(modifier = Modifier.width(70.dp), text = i18nState.getString("structural_element") + ":", color = Color.Unspecified) basicTextFieldWithTitle(titleText = i18nState.getString("width"), widthText) { str -> widthText = str } basicTextFieldWithTitle(titleText = i18nState.getString("height"), heightText) { str -> heightText = str } } } Button( modifier = Modifier.padding(top = 10.dp).align(Alignment.End), onClick = experimentViewClick(state) { if(state.currentImage?.type == BufferedImage.TYPE_BYTE_BINARY) { val width = getValidateField(block = { widthText.toInt() } , failed = { val errorMsg = i18nState.getString("width_needs_int_for_morph") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick val height = getValidateField(block = { heightText.toInt() } , failed = { val errorMsg = i18nState.getString("height_needs_int_for_morph") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) }) ?: return@experimentViewClick morphologicalOperationSettings = morphologicalOperationSettings.copy(width = width, height = height) viewModel.morphologyEx(state, morphologicalOperationSettings) } else { val errorMsg = i18nState.getString("please_binarize_image_first") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.MEDIUM, errorMsg, errorMsg) } } ) { Text(text = i18nState.getString("morphological_operations"), color = Color.Unspecified) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/NavController.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.NavController * @author: Tony Shen * @date: 2024/9/23 20:04 * @version: V1.0 <描述当前版本功能> */ class NavController( private val startDestination: String, private var backStackScreens: MutableSet = mutableSetOf() ) { // Variable to store the state of the current screen var currentScreen: MutableState = mutableStateOf(startDestination) // Function to handle the navigation between the screen fun navigate(route: String) { if (route != currentScreen.value) { if (backStackScreens.contains(currentScreen.value) && currentScreen.value != startDestination) { backStackScreens.remove(currentScreen.value) } if (route == startDestination) { backStackScreens = mutableSetOf() } else { backStackScreens.add(currentScreen.value) } currentScreen.value = route } } // Function to handle the back fun navigateBack() { if (backStackScreens.isNotEmpty()) { currentScreen.value = backStackScreens.last() backStackScreens.remove(currentScreen.value) } } } /** * Composable to remember the state of the navcontroller */ @Composable fun rememberNavController( startDestination: String, backStackScreens: MutableSet = mutableSetOf() ): MutableState = rememberSaveable { mutableStateOf(NavController(startDestination, backStackScreens)) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/NavigationHost.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment import androidx.compose.runtime.Composable /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.NavigationHost * @author: Tony Shen * @date: 2024/9/23 20:06 * @version: V1.0 <描述当前版本功能> */ class NavigationHost( val navController: NavController, val contents: @Composable NavigationGraphBuilder.() -> Unit ) { @Composable fun build() { NavigationGraphBuilder().renderContents() } inner class NavigationGraphBuilder( val navController: NavController = this@NavigationHost.navController ) { @Composable fun renderContents() { this@NavigationHost.contents(this) } } } /** * Composable to build the Navigation Host */ @Composable fun NavigationHost.NavigationGraphBuilder.composable( route: String, content: @Composable () -> Unit ) { if (navController.currentScreen.value == route) { content() } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/viewmodel/BinaryImageViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel import cn.netdiscovery.monica.config.MODULE_OPENCV import cn.netdiscovery.monica.history.EditHistoryCenter import cn.netdiscovery.monica.history.modules.opencv.CVParams import cn.netdiscovery.monica.history.modules.opencv.recordCVOperation import cn.netdiscovery.monica.manager.OpenCVManager import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.extensions.launchWithLoading import cn.netdiscovery.monica.utils.logger import org.slf4j.Logger import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.BinaryImageAnalysisViewModel * @author: Tony Shen * @date: 2024/10/7 16:07 * @version: V1.0 <描述当前版本功能> */ class BinaryImageViewModel { private val logger: Logger = logger() private val manager = EditHistoryCenter.getManager(MODULE_OPENCV) fun cvtGray(state: ApplicationState) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_GRAY, action = { byteArray -> manager.recordCVOperation(operation = "cvtGray", description = "灰度化") {} ImageProcess.cvtGray(byteArray) }, failure = { e -> logger.error("cvtGray is failed", e) }) } } fun threshold(state: ApplicationState, typeSelected: String, thresholdSelected: String) { val thresholdType1 = when(typeSelected) { "THRESH_BINARY" -> 0 "THRESH_BINARY_INV" -> 1 else -> 0 } val thresholdType2 = when(thresholdSelected) { "THRESH_OTSU" -> 8 "THRESH_TRIANGLE" -> 16 else -> 8 } state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_BINARY, action = { byteArray -> manager.recordCVOperation(operation = "threshold", description = "阈值分割") { this.parameters["thresholdType1"] = thresholdType1 this.parameters["thresholdType2"] = thresholdType2 } ImageProcess.threshold(byteArray, thresholdType1, thresholdType2) }, failure = { e -> logger.error("threshold is failed", e) }) } } fun adaptiveThreshold(state: ApplicationState, adaptiveMethodSelected: String, typeSelected: String, blockSize:Int, c:Int) { val adaptiveMethod = when(adaptiveMethodSelected) { "ADAPTIVE_THRESH_MEAN_C" -> 0 "ADAPTIVE_THRESH_GAUSSIAN_C" -> 1 else -> 0 } val thresholdType = when(typeSelected) { "THRESH_BINARY" -> 0 "THRESH_BINARY_INV" -> 1 else -> 0 } state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_BINARY, action = { byteArray -> manager.recordCVOperation(operation = "adaptiveThreshold", description = "自适应阈值分割") { this.parameters["adaptiveMethod"] = adaptiveMethod this.parameters["thresholdType"] = thresholdType } ImageProcess.adaptiveThreshold(byteArray, adaptiveMethod, thresholdType, blockSize, c) }, failure = { e -> logger.error("adaptiveThreshold is failed", e) }) } } fun inRange(state: ApplicationState, hmin:Int, smin:Int, vmin:Int, hmax:Int, smax:Int, vmax:Int) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_BINARY, action = { byteArray -> manager.recordCVOperation(operation = "inRange", description = "颜色分割") { this.parameters["hmin"]=hmin this.parameters["smin"]=smin this.parameters["vmin"]=vmin this.parameters["hmax"]=hmax this.parameters["smax"]=smax this.parameters["vmax"]=vmax } ImageProcess.inRange(byteArray, hmin, smin, vmin, hmax, smax, vmax) }, failure = { e -> logger.error("inRange is failed", e) }) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/viewmodel/ContourAnalysisViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel import cn.netdiscovery.monica.config.MODULE_OPENCV import cn.netdiscovery.monica.manager.OpenCVManager import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.domain.ContourDisplaySettings import cn.netdiscovery.monica.domain.ContourFilterSettings import cn.netdiscovery.monica.history.EditHistoryCenter import cn.netdiscovery.monica.history.modules.opencv.CVParams import cn.netdiscovery.monica.history.modules.opencv.recordCVOperation import cn.netdiscovery.monica.imageprocess.utils.extension.image2ByteArray import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.utils.extensions.launchWithLoading import cn.netdiscovery.monica.utils.logger import com.safframework.rxcache.utils.GsonUtils import org.slf4j.Logger import java.awt.image.BufferedImage import kotlin.collections.set /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.ContourAnalysisViewModel * @author: Tony Shen * @date: 2024/10/26 13:54 * @version: V1.0 <描述当前版本功能> */ class ContourAnalysisViewModel { private val logger: Logger = logger() private val manager = EditHistoryCenter.getManager(MODULE_OPENCV) fun contourAnalysis(state: ApplicationState, contourFilterSettings: ContourFilterSettings, contourDisplaySettings: ContourDisplaySettings) { logger.info("contourFilterSettings = ${GsonUtils.toJson(contourFilterSettings)}") logger.info("contourDisplaySettings = ${GsonUtils.toJson(contourDisplaySettings)}") val type = if (contourDisplaySettings.showOriginalImage) { BufferedImage.TYPE_INT_ARGB } else BufferedImage.TYPE_BYTE_BINARY state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = type, action = { byteArray -> val srcByteArray = state.rawImage!!.image2ByteArray() manager.recordCVOperation(operation = "contourAnalysis", description = "轮廓分析") { this.parameters["contourFilterSettings"] = contourFilterSettings this.parameters["contourDisplaySettings"] = contourDisplaySettings } val scalar = state.toOutputBoxScalar() ImageProcess.contourAnalysis(srcByteArray, byteArray, scalar, contourFilterSettings, contourDisplaySettings) }, failure = { e -> logger.error("contourAnalysis is failed", e) }) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/viewmodel/EdgeDetectionViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel import cn.netdiscovery.monica.config.MODULE_OPENCV import cn.netdiscovery.monica.history.EditHistoryCenter import cn.netdiscovery.monica.history.modules.opencv.CVParams import cn.netdiscovery.monica.history.modules.opencv.recordCVOperation import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.manager.OpenCVManager import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.extensions.launchWithLoading import cn.netdiscovery.monica.utils.logger import org.slf4j.Logger import java.awt.image.BufferedImage import kotlin.collections.set /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.EdgeDetectionViewModel * @author: Tony Shen * @date: 2024/10/13 22:23 * @version: V1.0 <描述当前版本功能> */ class EdgeDetectionViewModel { private val logger: Logger = logger() private val manager = EditHistoryCenter.getManager(MODULE_OPENCV) fun roberts(state: ApplicationState) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_GRAY, action = { byteArray -> manager.recordCVOperation(operation = "roberts", description = "实现 roberts 算子") {} ImageProcess.roberts(byteArray) }, failure = { e -> logger.error("roberts is failed", e) }) } } fun prewitt(state: ApplicationState) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_GRAY, action = { byteArray -> manager.recordCVOperation(operation = "prewitt", description = "实现 prewitt 算子") {} ImageProcess.prewitt(byteArray) }, failure = { e -> logger.error("prewitt is failed", e) }) } } fun sobel(state: ApplicationState) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_GRAY, action = { byteArray -> manager.recordCVOperation(operation = "sobel", description = "实现 sobel 算子") {} ImageProcess.sobel(byteArray) }, failure = { e -> logger.error("sobel is failed", e) }) } } fun laplace(state: ApplicationState) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_GRAY, action = { byteArray -> manager.recordCVOperation(operation = "laplace", description = "实现 laplace 算子") {} ImageProcess.laplace(byteArray) }, failure = { e -> logger.error("laplace is failed", e) }) } } fun log(state: ApplicationState) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_GRAY, action = { byteArray -> manager.recordCVOperation(operation = "log", description = "实现 LoG 算子") {} ImageProcess.log(byteArray) }, failure = { e -> logger.error("log is failed", e) }) } } fun dog(state: ApplicationState, sigma1:Double, sigma2: Double, size:Int) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_GRAY, action = { byteArray -> manager.recordCVOperation(operation = "dog", description = "实现 DoG 算子") { this.parameters["sigma1"] = sigma1 this.parameters["sigma2"] = sigma2 this.parameters["size"] = size } ImageProcess.dog(byteArray, sigma1, sigma2, size) }, failure = { e -> logger.error("log is failed", e) }) } } fun canny(state: ApplicationState, threshold1:Double, threshold2: Double, apertureSize:Int) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_BINARY, action = { byteArray -> manager.recordCVOperation(operation = "canny", description = "实现 canny 算子") { this.parameters["threshold1"] = threshold1 this.parameters["threshold2"] = threshold2 this.parameters["apertureSize"] = apertureSize } ImageProcess.canny(byteArray,threshold1,threshold2,apertureSize) }, failure = { e -> logger.error("canny is failed", e) }) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/viewmodel/HistoryViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel import cn.netdiscovery.monica.config.MODULE_OPENCV import cn.netdiscovery.monica.history.EditHistoryCenter import cn.netdiscovery.monica.history.HistoryEntry import cn.netdiscovery.monica.history.modules.opencv.CVParams import cn.netdiscovery.monica.utils.logger import org.slf4j.Logger /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.HistoryViewModel * @author: Tony Shen * @date: 2025/7/30 09:52 * @version: V1.0 <描述当前版本功能> */ class HistoryViewModel { private val logger: Logger = logger() private val manager = EditHistoryCenter.getManager(MODULE_OPENCV) fun getOperationLog():List{ return manager.getOperationLog().asReversed() } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/viewmodel/ImageDenoisingViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel import cn.netdiscovery.monica.config.MODULE_OPENCV import cn.netdiscovery.monica.history.EditHistoryCenter import cn.netdiscovery.monica.history.modules.opencv.CVParams import cn.netdiscovery.monica.history.modules.opencv.recordCVOperation import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.manager.OpenCVManager import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.extensions.launchWithLoading import cn.netdiscovery.monica.utils.logger import org.slf4j.Logger import kotlin.collections.set /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.ImageDenoisingViewModel * @author: Tony Shen * @date: 2024/12/4 15:44 * @version: V1.0 <描述当前版本功能> */ class ImageDenoisingViewModel { private val logger: Logger = logger() private val manager = EditHistoryCenter.getManager(MODULE_OPENCV) fun gaussianBlur(state: ApplicationState, ksize:Int, sigmaX: Double = 0.0, sigmaY: Double = 0.0) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> manager.recordCVOperation(operation = "gaussianBlur", description = "实现高斯滤波") { this.parameters["ksize"] = ksize this.parameters["sigmaX"] = sigmaX this.parameters["sigmaY"] = sigmaY } ImageProcess.gaussianBlur(byteArray, ksize, sigmaX, sigmaY) }, failure = { e -> logger.error("gaussianBlur is failed", e) }) } } fun medianBlur(state: ApplicationState, ksize:Int) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> manager.recordCVOperation(operation = "medianBlur", description = "实现中值滤波") { this.parameters["ksize"] = ksize } ImageProcess.medianBlur(byteArray, ksize) }, failure = { e -> logger.error("medianBlur is failed", e) }) } } fun bilateralFilter(state: ApplicationState, d:Int, sigmaColor:Double, sigmaSpace:Double) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> manager.recordCVOperation(operation = "bilateralFilter", description = "实现高斯双边滤波") { this.parameters["d"] = d this.parameters["sigmaColor"] = sigmaColor this.parameters["sigmaSpace"] = sigmaSpace } ImageProcess.bilateralFilter(byteArray, d, sigmaColor, sigmaSpace) }, failure = { e -> logger.error("medianBlur is failed", e) }) } } fun pyrMeanShiftFiltering(state: ApplicationState, sp: Double, sr: Double) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> manager.recordCVOperation(operation = "pyrMeanShiftFiltering", description = "实现均值迁移滤波") { this.parameters["sp"] = sp this.parameters["sr"] = sr } ImageProcess.pyrMeanShiftFiltering(byteArray, sp, sr) }, failure = { e -> logger.error("medianBlur is failed", e) }) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/viewmodel/ImageEnhanceViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel import cn.netdiscovery.monica.config.MODULE_OPENCV import cn.netdiscovery.monica.history.EditHistoryCenter import cn.netdiscovery.monica.history.modules.opencv.CVParams import cn.netdiscovery.monica.history.modules.opencv.recordCVOperation import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.manager.OpenCVManager import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.extensions.launchWithLoading import cn.netdiscovery.monica.utils.logger import org.slf4j.Logger import kotlin.collections.set /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.ImageEnhanceViewModel * @author: Tony Shen * @date: 2024/7/17 21:33 * @version: V1.0 <描述当前版本功能> */ class ImageEnhanceViewModel { private val logger: Logger = logger() private val manager = EditHistoryCenter.getManager(MODULE_OPENCV) fun equalizeHist(state: ApplicationState) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> manager.recordCVOperation(operation = "equalizeHist", description = "直方图均衡化") {} ImageProcess.equalizeHist(byteArray) }, failure = { e -> logger.error("equalizeHist is failed", e) }) } } fun clahe(state: ApplicationState, clipLimit:Double, size:Int) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> manager.recordCVOperation(operation = "clahe", description = "限制对比度自适应直方图均衡") { this.parameters["clipLimit"] = clipLimit this.parameters["size"] = size } ImageProcess.clahe(byteArray, clipLimit, size) }, failure = { e -> logger.error("clahe is failed", e) }) } } fun gammaCorrection(state: ApplicationState, gamma:Float) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> manager.recordCVOperation(operation = "gammaCorrection", description = "gamma 校正") { this.parameters["gamma"] = gamma } ImageProcess.gammaCorrection(byteArray, gamma) }, failure = { e -> logger.error("gammaCorrection is failed", e) }) } } fun laplaceSharpening(state: ApplicationState) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> manager.recordCVOperation(operation = "laplaceSharpening", description = "laplace 锐化") {} ImageProcess.laplaceSharpening(byteArray) }, failure = { e -> logger.error("laplace is failed", e) }) } } fun unsharpMask(state: ApplicationState,radius:Int,threshold:Int,amount:Int) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> manager.recordCVOperation(operation = "unsharpMask", description = "USM 锐化") { this.parameters["radius"] = radius this.parameters["threshold"] = threshold this.parameters["amount"] = amount } ImageProcess.unsharpMask(byteArray,radius,threshold,amount) }, failure = { e -> logger.error("unsharpMask is failed", e) }) } } fun ace(state: ApplicationState, ratio:Int, radius:Int) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> manager.recordCVOperation(operation = "ace", description = "自动色彩均衡") { this.parameters["ratio"] = ratio this.parameters["radius"] = radius } ImageProcess.ace(byteArray,ratio,radius) }, failure = { e -> logger.error("ace is failed", e) }) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/viewmodel/MatchTemplateViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel import cn.netdiscovery.monica.config.MODULE_OPENCV import cn.netdiscovery.monica.domain.MatchTemplateSettings import cn.netdiscovery.monica.history.EditHistoryCenter import cn.netdiscovery.monica.history.modules.opencv.CVParams import cn.netdiscovery.monica.history.modules.opencv.recordCVOperation import cn.netdiscovery.monica.imageprocess.utils.extension.image2ByteArray import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.manager.OpenCVManager import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.ai.experiment.CVState import cn.netdiscovery.monica.utils.extensions.launchWithLoading import cn.netdiscovery.monica.utils.logger import com.safframework.rxcache.utils.GsonUtils import org.slf4j.Logger /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.MatchTemplateViewModel * @author: Tony Shen * @date: 2025/1/1 15:32 * @version: V1.0 <描述当前版本功能> */ class MatchTemplateViewModel { private val logger: Logger = logger() private val manager = EditHistoryCenter.getManager(MODULE_OPENCV) fun clearTemplateImage() { if (CVState.templateImage!=null) { CVState.templateImage = null } } fun matchTemplate(state: ApplicationState, matchTemplateSettings: MatchTemplateSettings) { logger.info("matchTemplateSettings = ${GsonUtils.toJson(matchTemplateSettings)}") if (CVState.templateImage != null) { state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> val templateByteArray = CVState.templateImage!!.image2ByteArray() val scalar = state.toOutputBoxScalar() manager.recordCVOperation(operation = "matchTemplate", description = "模版匹配") { this.parameters["matchTemplateSettings"] = matchTemplateSettings } ImageProcess.matchTemplate(byteArray, templateByteArray, scalar, matchTemplateSettings) }, failure = { e -> logger.error("contourAnalysis is failed", e) }) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/experiment/viewmodel/MorphologicalOperationsViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel import cn.netdiscovery.monica.config.MODULE_OPENCV import cn.netdiscovery.monica.manager.OpenCVManager import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.domain.MorphologicalOperationSettings import cn.netdiscovery.monica.history.EditHistoryCenter import cn.netdiscovery.monica.history.modules.opencv.CVParams import cn.netdiscovery.monica.history.modules.opencv.recordCVOperation import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.utils.extensions.launchWithLoading import cn.netdiscovery.monica.utils.logger import com.safframework.rxcache.utils.GsonUtils import org.slf4j.Logger import java.awt.image.BufferedImage import kotlin.collections.set /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.experiment.viewmodel.MorphologicalOperationsViewModel * @author: Tony Shen * @date: 2024/12/26 20:21 * @version: V1.0 <描述当前版本功能> */ class MorphologicalOperationsViewModel { private val logger: Logger = logger() private val manager = EditHistoryCenter.getManager(MODULE_OPENCV) fun morphologyEx(state: ApplicationState, morphologicalOperationSettings: MorphologicalOperationSettings) { logger.info("morphologicalOperationSettings = ${GsonUtils.toJson(morphologicalOperationSettings)}") state.scope.launchWithLoading { OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_BINARY, action = { byteArray -> manager.recordCVOperation(operation = "morphologyEx", description = "形态学操作") { this.parameters["morphologicalOperationSettings"] = morphologicalOperationSettings } ImageProcess.morphologyEx(byteArray, morphologicalOperationSettings) }, failure = { e -> logger.error("contourAnalysis is failed", e) }) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/faceswap/FaceSwapView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.faceswap import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.ui.widget.* import cn.netdiscovery.monica.utils.chooseImage import cn.netdiscovery.monica.utils.getBufferedImage import loadingDisplay import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.faceswap.FaceSwapView * @author: Tony Shen * @date: 2024/8/25 13:02 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) private var showToast by mutableStateOf(false) private var toastMessage by mutableStateOf("") @OptIn(ExperimentalMaterialApi::class) @Composable fun faceSwap(state: ApplicationState) { val i18nState = rememberI18nState() val viewModel: FaceSwapViewModel = koinInject() val showSwapFaceSettings = remember { mutableStateOf(false) } val selectedOption = remember { mutableStateOf(false) } PageLifecycle( onInit = { logger.info("FaceSwapView 启动时初始化") }, onDisposeEffect = { logger.info("FaceSwapView 关闭时释放资源") viewModel.clearTargetImage() } ) Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column (modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { Row ( modifier = Modifier.fillMaxSize().padding(top= 20.dp, bottom = 20.dp, end = 90.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { Card( modifier = Modifier.padding(10.dp).weight(1.0f), shape = RoundedCornerShape(8.dp), elevation = 4.dp, onClick = { chooseImage(state) { file -> state.rawImage = getBufferedImage(file, state) state.currentImage = state.rawImage state.rawImageFile = file } }, enabled = state.currentImage == null ) { if (state.currentImage == null) { Text( modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center), text = i18nState.getString("click_to_select_image"), textAlign = TextAlign.Center ) } else { Box { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = "source", textAlign = TextAlign.Center, color = MaterialTheme.colors.primary, fontSize = 36.sp, fontWeight = FontWeight.Bold ) Image( painter = state.currentImage!!.toPainter(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier ) } Row(modifier = Modifier.align(Alignment.TopEnd)) { toolTipButton(text = "上一步", buttonModifier = Modifier, iconModifier = Modifier.size(20.dp), painter = painterResource("images/doodle/previous_step.png"), onClick = { viewModel.getLastSourceImage(state) }) toolTipButton(text = "检测 source 图中的人脸", painter = painterResource("images/ai/face_landmark.png"), buttonModifier = Modifier, iconModifier = Modifier.size(20.dp), onClick = { viewModel.faceLandMark(state, state.currentImage, state.rawImageFile, success = { state.addQueue(state.currentImage!!) state.currentImage = it }, failure = { showToast("算法服务异常") }) }) toolTipButton(text = "删除 source 的图", painter = painterResource("images/preview/delete.png"), buttonModifier = Modifier, iconModifier = Modifier.size(20.dp), onClick = { state.clearImage() }) } } } } Card( modifier = Modifier.padding(10.dp).weight(1.0f), shape = RoundedCornerShape(8.dp), elevation = 4.dp, onClick = { chooseImage(state) { file -> viewModel.targetImage = getBufferedImage(file) viewModel.targetImageFile = file } }, enabled = viewModel.targetImage == null ) { if (viewModel.targetImage == null) { Text( modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center), text = "请点击选择图像", textAlign = TextAlign.Center ) } else { Box { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = "target", textAlign = TextAlign.Center, color = MaterialTheme.colors.primary, fontSize = 36.sp, fontWeight = FontWeight.Bold ) Image( painter = viewModel.targetImage!!.toPainter(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier) } Row(modifier = Modifier.align(Alignment.TopEnd)) { toolTipButton(text = "上一步", buttonModifier = Modifier, iconModifier = Modifier.size(20.dp), painter = painterResource("images/doodle/previous_step.png"), onClick = { if (viewModel.lastTargetImage!=null) { viewModel.targetImage = viewModel.lastTargetImage } }) toolTipButton(text = "检测 target 图中的人脸", painter = painterResource("images/ai/face_landmark.png"), buttonModifier = Modifier, iconModifier = Modifier.size(20.dp), onClick = { viewModel.faceLandMark(state, viewModel.targetImage, viewModel.targetImageFile, success = { viewModel.lastTargetImage = viewModel.targetImage viewModel.targetImage = it }, failure = { showToast("算法服务异常") }) }) toolTipButton(text = "删除 target 的图", painter = painterResource("images/preview/delete.png"), buttonModifier = Modifier, iconModifier = Modifier.size(20.dp), onClick = { viewModel.clearTargetImage() }) } } } } } } rightSideMenuBar(modifier = Modifier.align(Alignment.CenterEnd)) { toolTipButton(text = "设置", painter = painterResource("images/cropimage/settings.png"), iconModifier = Modifier.size(36.dp), onClick = { showSwapFaceSettings.value = true }) toolTipButton(text = "人脸替换", painter = painterResource("images/ai/face_swap2.png"), iconModifier = Modifier.size(36.dp), onClick = { if (state.currentImage!=null && viewModel.targetImage!=null) { viewModel.faceSwap(state, state.currentImage, viewModel.targetImage, selectedOption.value, success = { viewModel.lastTargetImage = viewModel.targetImage viewModel.targetImage = it }, failure = { showToast("算法服务异常") }) } }) toolTipButton(text = "保存结果", painter = painterResource("images/doodle/save.png"), iconModifier = Modifier.size(36.dp), onClick = { if (viewModel.targetImage!=null) { state.addQueue(state.currentImage!!) state.currentImage = viewModel.targetImage } state.togglePreviewWindow(false) }) } if (loadingDisplay) { showLoading() } if (showToast) { centerToast(message = toastMessage) { showToast = false } } if (showSwapFaceSettings.value) { AlertDialog(onDismissRequest = {}, title = { Text(i18nState.getString("replace_target_face_count")) }, text = { Column { Row { RadioButton( selected = !selectedOption.value, onClick = { selectedOption.value = false } ) Text("替换1个人脸", modifier = Modifier.align(Alignment.CenterVertically)) } Row { RadioButton( selected = selectedOption.value, onClick = { selectedOption.value = true } ) Text("替换全部的人脸", modifier = Modifier.align(Alignment.CenterVertically)) } } }, confirmButton = { Button(onClick = { showSwapFaceSettings.value = false }) { Text(i18nState.getString("close")) } }) } } } private fun showToast(message: String) { toastMessage = message showToast = true } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/ai/faceswap/FaceSwapViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.ai.faceswap import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import cn.netdiscovery.monica.http.createRequest import cn.netdiscovery.monica.http.createRequestBody import cn.netdiscovery.monica.imageprocess.utils.writeImageFile import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.CVFailure import cn.netdiscovery.monica.utils.CVSuccess import cn.netdiscovery.monica.utils.ImageFormatDetector import cn.netdiscovery.monica.utils.extensions.launchWithSuspendLoading import cn.netdiscovery.monica.utils.logger import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.asRequestBody import org.slf4j.Logger import java.awt.image.BufferedImage import java.io.File /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.ai.faceswap.FaceSwapModel * @author: Tony Shen * @date: 2024/8/25 14:55 * @version: V1.0 <描述当前版本功能> */ class FaceSwapViewModel { private val logger: Logger = logger() var targetImage: BufferedImage? by mutableStateOf(null) var targetImageFile: File? = null var lastTargetImage: BufferedImage? by mutableStateOf(null) fun clearTargetImage() { if (targetImage!=null) { targetImage = null } if (lastTargetImage!=null) { lastTargetImage = null } } fun faceLandMark(state: ApplicationState, image: BufferedImage?=null, file: File?=null, success:CVSuccess, failure:CVFailure) { if (image == null || file == null) return state.scope.launchWithSuspendLoading { createRequest(request = { val format = ImageFormatDetector.getImageFormat(file)?:"jpg" val requestBody: RequestBody = createRequestBody(image ,format) Request.Builder() .url( "${state.algorithmUrlText}api/faceLandMark") .post(requestBody) .build() }, success = { success.invoke(it) }, failure = { logger.error(it.message) failure.invoke(it) }) } } fun faceSwap(state: ApplicationState, image: BufferedImage?=null, target: BufferedImage?=null, status:Boolean, success:CVSuccess, failure:CVFailure) { if (image == null || target == null) return state.scope.launchWithSuspendLoading { val srcFileName = "temp_src.jpg" val targetFileName = "temp_target.jpg" writeImageFile(image,srcFileName,"jpg") writeImageFile(target,targetFileName,"jpg") val srcFile = File(srcFileName) val targetFile = File(targetFileName) createRequest(request = { // 构建 multipart 请求体 val requestBody = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("src", srcFileName, srcFile.asRequestBody("image/jpeg".toMediaType())) .addFormDataPart("target", targetFileName, targetFile.asRequestBody("image/jpeg".toMediaType())) .build() Request.Builder() .url("${state.algorithmUrlText}api/faceSwap?status=$status") .post(requestBody) .build() }, success = { success.invoke(it) srcFile.delete() targetFile.delete() }, failure = { logger.error(it.message) failure.invoke(it) }) } } fun getLastSourceImage(state: ApplicationState) { state.getLastImage()?.let { state.currentImage = it } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cartoon/CartoonView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cartoon import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.toPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.ui.widget.* import cn.netdiscovery.monica.utils.chooseImage import cn.netdiscovery.monica.utils.getBufferedImage import loadingDisplay import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cartoon.CartoonView * @author: Tony Shen * @date: 2025/4/16 17:32 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) private var showToast by mutableStateOf(false) private var toastMessage by mutableStateOf("") @OptIn(ExperimentalMaterialApi::class) @Composable fun cartoon(state: ApplicationState) { val i18nState = rememberI18nState() val viewModel: CartoonViewModel = koinInject() Box( modifier = Modifier .fillMaxSize() .background( brush = Brush.verticalGradient( colors = listOf( MaterialTheme.colors.background, MaterialTheme.colors.surface ) ) ), contentAlignment = Alignment.Center ) { Row ( modifier = Modifier.fillMaxSize().padding(bottom = 160.dp, end = 90.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { Card( modifier = Modifier.fillMaxSize().padding(10.dp), shape = RoundedCornerShape(8.dp), elevation = 4.dp, onClick = { chooseImage(state) { file -> state.rawImage = getBufferedImage(file, state) state.currentImage = state.rawImage state.rawImageFile = file } }, enabled = state.currentImage == null ) { if (state.currentImage == null) { Text( modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center), text = i18nState.getString("click_to_select_image"), textAlign = TextAlign.Center, fontSize = 24.sp ) } else { Image( painter = state.currentImage!!.toPainter(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier ) } } } Column(modifier = Modifier.padding(start = 20.dp, bottom = 20.dp, top = 160.dp).align(Alignment.BottomStart)) { subTitle(text = i18nState.getString("select_anime_style"), modifier = Modifier.padding(start = 10.dp), fontWeight = FontWeight.Bold) desktopLazyRow(modifier = Modifier.fillMaxWidth().padding(top = 10.dp).height(100.dp)) { Card( elevation = 16.dp, modifier = Modifier.fillMaxSize().padding(start = 5.dp).clickable{ viewModel.convert2Cartoon(state,1) { showToast(i18nState.getString("algorithm_service_error")) } } ) { Text( text = i18nState.getString("miyazaki_style"), fontSize = 22.sp, color = MaterialTheme.colors.primary, modifier = Modifier.width(200.dp).wrapContentSize(Alignment.Center) ) } Card( elevation = 16.dp, modifier = Modifier.fillMaxSize().padding(start = 5.dp).clickable{ viewModel.convert2Cartoon(state,2) { showToast(i18nState.getString("algorithm_service_error")) } } ) { Text( text = i18nState.getString("japanese_portrait_style"), fontSize = 22.sp, color = MaterialTheme.colors.primary, modifier = Modifier.width(200.dp).wrapContentSize(Alignment.Center) ) } Card( elevation = 16.dp, modifier = Modifier.fillMaxSize().padding(start = 5.dp).clickable{ viewModel.convert2Cartoon(state,3) { showToast(i18nState.getString("algorithm_service_error")) } } ) { Text( text = i18nState.getString("black_white_line_art"), fontSize = 22.sp, color = MaterialTheme.colors.primary, modifier = Modifier.width(200.dp).wrapContentSize(Alignment.Center) ) } Card( elevation = 16.dp, modifier = Modifier.fillMaxSize().padding(start = 5.dp).clickable{ viewModel.convert2Cartoon(state,4) { showToast(i18nState.getString("algorithm_service_error")) } } ) { Text( text = i18nState.getString("shinkai_style"), fontSize = 22.sp, color = MaterialTheme.colors.primary, modifier = Modifier.width(200.dp).wrapContentSize(Alignment.Center) ) } Card( elevation = 16.dp, modifier = Modifier.fillMaxSize().padding(start = 5.dp).clickable{ viewModel.convert2Cartoon(state,5) { showToast(i18nState.getString("algorithm_service_error")) } } ) { Text( text = i18nState.getString("cute_style"), fontSize = 22.sp, color = MaterialTheme.colors.primary, modifier = Modifier.width(200.dp).wrapContentSize(Alignment.Center) ) } } } rightSideMenuBar(modifier = Modifier.align(Alignment.CenterEnd)) { toolTipButton(text = i18nState.getString("delete"), painter = painterResource("images/preview/delete.png"), iconModifier = Modifier.size(36.dp), onClick = { state.clearImage() }) toolTipButton(text = i18nState.getString("previous_step"), painter = painterResource("images/doodle/previous_step.png"), onClick = { state.getLastImage()?.let { state.currentImage = it } }) toolTipButton(text = i18nState.getString("save"), painter = painterResource("images/doodle/save.png"), onClick = { state.closePreviewWindow() }) } if (loadingDisplay) { showLoading() } if (showToast) { centerToast(message = toastMessage) { showToast = false } } } } private fun showToast(message: String) { toastMessage = message showToast = true } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cartoon/CartoonViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cartoon import cn.netdiscovery.monica.http.createRequest import cn.netdiscovery.monica.http.createRequestBody import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.CVFailure import cn.netdiscovery.monica.utils.ImageFormatDetector import cn.netdiscovery.monica.utils.extensions.launchWithSuspendLoading import cn.netdiscovery.monica.utils.logger import okhttp3.Request import okhttp3.RequestBody import org.slf4j.Logger /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cartoon.CartoonViewModel * @author: Tony Shen * @date: 2025/4/16 18:21 * @version: V1.0 <描述当前版本功能> */ class CartoonViewModel { private val logger: Logger = logger() fun convert2Cartoon(state: ApplicationState, type:Int, failure: CVFailure) { if (state.currentImage == null) return state.scope.launchWithSuspendLoading { createRequest(request = { val format = ImageFormatDetector.getImageFormat(state.rawImageFile!!)?:"jpg" val requestBody: RequestBody = createRequestBody(state.currentImage!!,format) Request.Builder() .url( "${state.algorithmUrlText}api/cartoon?type=$type") .post(requestBody) .build() }, success = { state.addQueue(state.currentImage!!) state.currentImage = it }, failure = { logger.error(it.message) failure.invoke(it) }) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorcorrection/ColorCorrectionView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorcorrection import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Slider import androidx.compose.material.SliderDefaults import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.config.MODULE_COLOR import cn.netdiscovery.monica.domain.ColorCorrectionSettings import cn.netdiscovery.monica.history.EditHistoryCenter import cn.netdiscovery.monica.history.modules.colorcorrection.ColorCorrectionParams import cn.netdiscovery.monica.history.modules.colorcorrection.recordColorCorrection import cn.netdiscovery.monica.llm.DialogSession import cn.netdiscovery.monica.llm.systemPromptForColorCorrection import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.widget.PageLifecycle import cn.netdiscovery.monica.ui.widget.showLoading import cn.netdiscovery.monica.ui.widget.toolTipButton import cn.netdiscovery.monica.ui.widget.image.ImageSizeCalculator import cn.netdiscovery.monica.utils.extensions.to2fStr import cn.netdiscovery.monica.i18n.getCurrentStringResource import cn.netdiscovery.monica.imageprocess.utils.extension.image2ByteArray import cn.netdiscovery.monica.manager.OpenCVManager import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.utils.extensions.launchWithSuspendLoading import com.safframework.rxcache.utils.GsonUtils import loadingDisplay import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory import kotlin.math.roundToInt /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorcorrection.ColorCorrectionView * @author: Tony Shen * @date: 2024/11/5 15:05 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) var colorCorrectionSettings = ColorCorrectionSettings() private var showLLMDialog by mutableStateOf(false) @OptIn(ExperimentalMaterialApi::class) @Composable fun colorCorrection(state: ApplicationState) { val viewModel: ColorCorrectionViewModel = koinInject() val density = LocalDensity.current val i18nState = getCurrentStringResource() var cachedImage by remember { mutableStateOf(state.currentImage!!) } // 缓存 state.currentImage val enableSlider = !loadingDisplay val session = remember { DialogSession(systemPromptForColorCorrection, colorCorrectionSettings) } // 使用统一的图片尺寸计算 val (imageWidth, imageHeight) = ImageSizeCalculator.calculateImageSize(state) // 获取原始图片尺寸和显示尺寸,用于坐标转换 val originalSize = ImageSizeCalculator.getImagePixelSize(state) PageLifecycle( onInit = { logger.info("ColorCorrectionView 启动时初始化") }, onDisposeEffect = { logger.info("ColorCorrectionView 关闭时释放资源") viewModel.clearAllStatus() } ) Box( modifier = Modifier .fillMaxSize() .background( brush = Brush.verticalGradient( colors = listOf( MaterialTheme.colors.background, MaterialTheme.colors.surface ) ) ), contentAlignment = Alignment.Center ) { Row ( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { Card( modifier = Modifier .padding(10.dp) .weight(1.4f) .width(imageWidth) .height(imageHeight), shape = RoundedCornerShape(8.dp), elevation = 4.dp, onClick = { }, enabled = false ) { Image( bitmap = cachedImage.toComposeImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize() ) } Row(modifier = Modifier.weight(0.6f) .padding(start = 10.dp, end = 10.dp) .background(color = MaterialTheme.colors.surface, shape = RoundedCornerShape(5))) { Column( Modifier.padding(20.dp), verticalArrangement = Arrangement.Center ) { Row(verticalAlignment = Alignment.CenterVertically) { Text(modifier = Modifier.width(100.dp), text = i18nState.get("contrast") + ":", color = MaterialTheme.colors.onSurface) Row(verticalAlignment = Alignment.CenterVertically) { Slider( value = viewModel.contrast, onValueChange = { val value = it.roundToInt() viewModel.contrast = value.toFloat() colorCorrectionSettings = colorCorrectionSettings.copy(contrast = value, status = 1) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings) { image-> cachedImage = image } }, enabled = enableSlider, modifier = Modifier.weight(9f), valueRange = 0f..510f, colors = SliderDefaults.colors()) Text( text = viewModel.contrast.to2fStr(), color = MaterialTheme.colors.onSurface, modifier = Modifier.weight(1f)) } } Row(verticalAlignment = Alignment.CenterVertically) { Text(modifier = Modifier.width(100.dp), text = i18nState.get("hue") + ":", color = MaterialTheme.colors.onSurface) Row(verticalAlignment = Alignment.CenterVertically) { Slider( value = viewModel.hue, onValueChange = { val value = it.roundToInt() viewModel.hue = value.toFloat() colorCorrectionSettings = colorCorrectionSettings.copy(hue = value, status = 2) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings) { image-> cachedImage = image } }, enabled = enableSlider, modifier = Modifier.weight(9f), valueRange = 0f..360f) Text( text = viewModel.hue.to2fStr(), color = MaterialTheme.colors.onSurface, modifier = Modifier.weight(1f)) } } Row(verticalAlignment = Alignment.CenterVertically) { Text(modifier = Modifier.width(100.dp), text = i18nState.get("saturation") + ":", color = MaterialTheme.colors.onSurface) Row(verticalAlignment = Alignment.CenterVertically) { Slider( value = viewModel.saturation, onValueChange = { val value = it.roundToInt() viewModel.saturation = value.toFloat() colorCorrectionSettings = colorCorrectionSettings.copy(saturation = value, status = 3) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings) { image-> cachedImage = image } }, enabled = enableSlider, modifier = Modifier.weight(9f), valueRange = 0f..510f, colors = SliderDefaults.colors()) Text( text = viewModel.saturation.to2fStr(), color = MaterialTheme.colors.onSurface, modifier = Modifier.weight(1f)) } } Row(verticalAlignment = Alignment.CenterVertically) { Text(modifier = Modifier.width(100.dp), text = i18nState.get("lightness") + ":", color = MaterialTheme.colors.onSurface) Row(verticalAlignment = Alignment.CenterVertically) { Slider( value = viewModel.lightness, onValueChange = { val value = it.roundToInt() viewModel.lightness = value.toFloat() colorCorrectionSettings = colorCorrectionSettings.copy(lightness = value, status = 4) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings) { image-> cachedImage = image } }, enabled = enableSlider, modifier = Modifier.weight(9f), valueRange = 0f..510f, colors = SliderDefaults.colors()) Text( text = viewModel.lightness.to2fStr(), color = MaterialTheme.colors.onSurface, modifier = Modifier.weight(1f)) } } Row(verticalAlignment = Alignment.CenterVertically) { Text(modifier = Modifier.width(100.dp), text = i18nState.get("temperature") + ":", color = MaterialTheme.colors.onSurface) Row(verticalAlignment = Alignment.CenterVertically) { Slider( value = viewModel.temperature, onValueChange = { val value = it.roundToInt() viewModel.temperature = value.toFloat() colorCorrectionSettings = colorCorrectionSettings.copy(temperature = value, status = 5) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings) { image-> cachedImage = image } }, enabled = enableSlider, modifier = Modifier.weight(9f), valueRange = 0f..510f, colors = SliderDefaults.colors()) Text( text = viewModel.temperature.to2fStr(), color = MaterialTheme.colors.onSurface, modifier = Modifier.weight(1f)) } } Row(verticalAlignment = Alignment.CenterVertically) { Text(modifier = Modifier.width(100.dp), text = i18nState.get("highlight") + ":", color = MaterialTheme.colors.onSurface) Row(verticalAlignment = Alignment.CenterVertically) { Slider( value = viewModel.highlight, onValueChange = { val value = it.roundToInt() viewModel.highlight = value.toFloat() colorCorrectionSettings = colorCorrectionSettings.copy(highlight = value, status = 6) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings) { image-> cachedImage = image } }, enabled = enableSlider, modifier = Modifier.weight(9f), valueRange = 0f..510f, colors = SliderDefaults.colors()) Text( text = viewModel.highlight.to2fStr(), color = MaterialTheme.colors.onSurface, modifier = Modifier.weight(1f)) } } Row(verticalAlignment = Alignment.CenterVertically) { Text(modifier = Modifier.width(100.dp), text = i18nState.get("shadow") + ":", color = MaterialTheme.colors.onSurface) Row(verticalAlignment = Alignment.CenterVertically) { Slider( value = viewModel.shadow, onValueChange = { val value = it.roundToInt() viewModel.shadow = value.toFloat() colorCorrectionSettings = colorCorrectionSettings.copy(shadow = value, status = 7) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings) { image-> cachedImage = image } }, enabled = enableSlider, modifier = Modifier.weight(9f), valueRange = 0f..510f, colors = SliderDefaults.colors()) Text( text = viewModel.shadow.to2fStr(), color = MaterialTheme.colors.onSurface, modifier = Modifier.weight(1f)) } } Row(verticalAlignment = Alignment.CenterVertically) { Text(modifier = Modifier.width(100.dp), text = i18nState.get("sharpen") + ":", color = MaterialTheme.colors.onSurface) Row(verticalAlignment = Alignment.CenterVertically) { Slider( value = viewModel.sharpen, onValueChange = { val value = it.roundToInt() viewModel.sharpen = value.toFloat() colorCorrectionSettings = colorCorrectionSettings.copy(sharpen = value, status = 8) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings) { image-> cachedImage = image } }, enabled = enableSlider, modifier = Modifier.weight(9f), valueRange = 0f..255f) Text( text = viewModel.sharpen.to2fStr(), color = MaterialTheme.colors.onSurface, modifier = Modifier.weight(1f)) } } Row(verticalAlignment = Alignment.CenterVertically) { Text(modifier = Modifier.width(100.dp), text = i18nState.get("corner") + ":", color = MaterialTheme.colors.onSurface) Row(verticalAlignment = Alignment.CenterVertically) { Slider( value = viewModel.corner, onValueChange = { val value = it.roundToInt() viewModel.corner = value.toFloat() colorCorrectionSettings = colorCorrectionSettings.copy(corner = value, status = 9) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings) { image-> cachedImage = image } }, enabled = enableSlider, modifier = Modifier.weight(9f), valueRange = 0f..255f) Text( text = viewModel.corner.to2fStr(), color = MaterialTheme.colors.onSurface, modifier = Modifier.weight(1f)) } } // 底部菜单 Row(modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 10.dp)) { // 保存 toolTipButton(text = i18nState.get("save"), painter = painterResource("images/doodle/save.png"), iconModifier = Modifier.size(36.dp), onClick = { viewModel.save(state) { state.addQueue(state.currentImage!!) state.currentImage = cachedImage state.togglePreviewWindow(false) } }) // 自然语言图像调色 toolTipButton(text = i18nState.get("natural_language_color_correction"), painter = painterResource("images/colorcorrection/chatbot.png"), iconModifier = Modifier.size(36.dp), onClick = { showLLMDialog = true }) // 上一步 toolTipButton(text = i18nState.get("previous_step"), painter = painterResource("images/doodle/previous_step.png"), iconModifier = Modifier.size(36.dp), onClick = { viewModel.undo { lastSettings-> logger.info("lastSettings = ${lastSettings}") val lastStatus = colorCorrectionSettings.status colorCorrectionSettings = lastSettings.copy(status = lastStatus) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings,false) { image-> cachedImage = image } } }) // 撤回 toolTipButton(text = i18nState.get("revoke"), painter = painterResource("images/doodle/revoke.png"), onClick = { viewModel.redo { lastSettings-> val lastStatus = colorCorrectionSettings.status colorCorrectionSettings = lastSettings.copy(status = lastStatus) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings, false) { image-> cachedImage = image } } }) } } } } if (loadingDisplay) { showLoading() } if (showLLMDialog) { NaturalLanguageDialog(showLLMDialog, session, state.deepSeekApiKeyText, state.geminiApiKeyText, onDismissRequest = { showLLMDialog = false }) { colorCorrectionSettings = it viewModel.updateParams(colorCorrectionSettings) viewModel.colorCorrection(state, cachedImage, colorCorrectionSettings) { image -> cachedImage = image } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorcorrection/ColorCorrectionViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorcorrection import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import cn.netdiscovery.monica.config.MODULE_COLOR import cn.netdiscovery.monica.domain.ColorCorrectionSettings import cn.netdiscovery.monica.history.EditHistoryCenter import cn.netdiscovery.monica.history.modules.colorcorrection.ColorCorrectionParams import cn.netdiscovery.monica.history.modules.colorcorrection.recordColorCorrection import cn.netdiscovery.monica.imageprocess.BufferedImages import cn.netdiscovery.monica.imageprocess.utils.extension.image2ByteArray import cn.netdiscovery.monica.manager.OpenCVManager import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.* import cn.netdiscovery.monica.utils.extensions.launchWithLoading import cn.netdiscovery.monica.utils.extensions.launchWithSuspendLoading import com.safframework.rxcache.utils.GsonUtils import org.slf4j.Logger import java.awt.image.BufferedImage import java.util.concurrent.atomic.AtomicBoolean /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorcorrection.ColorCorrectionViewModel * @author: Tony Shen * @date: 2024/11/5 15:17 * @version: V1.0 <描述当前版本功能> */ class ColorCorrectionViewModel { private val logger: Logger = logger() private val manager = EditHistoryCenter.getManager(MODULE_COLOR) var contrast by mutableStateOf(255f ) var hue by mutableStateOf(180f ) var saturation by mutableStateOf(255f ) var lightness by mutableStateOf(255f ) var temperature by mutableStateOf(255f ) var highlight by mutableStateOf(255f ) var shadow by mutableStateOf(255f ) var sharpen by mutableStateOf(0f ) var corner by mutableStateOf(0f ) private var cppObjectPtr:Long = 0 private var init:AtomicBoolean = AtomicBoolean(false) fun updateParams(params: ColorCorrectionSettings) { contrast = params.contrast.toFloat() hue = params.hue.toFloat() saturation = params.saturation.toFloat() lightness = params.lightness.toFloat() temperature = params.temperature.toFloat() highlight = params.highlight.toFloat() shadow = params.shadow.toFloat() sharpen = params.sharpen.toFloat() corner = params.corner.toFloat() } /** * 封装图像调色的方法 * @param state 当前应用的 state * @param image 需要调色的图像 * @param colorCorrectionSettings 图像调色所需要的参数 * @param isPush 是否需要记录一次新的编辑操作。默认为 true,如果使用了 undo()、redo() 操作就为 false * @param success 成功后的回调 */ fun colorCorrection(state: ApplicationState, image: BufferedImage, colorCorrectionSettings: ColorCorrectionSettings, isPush: Boolean = true, success: CVSuccess) { logger.info("colorCorrectionSettings = ${GsonUtils.toJson(colorCorrectionSettings)}") state.scope.launchWithSuspendLoading { if (!init.get()) { init.set(true) val byteArray = image.image2ByteArray() cppObjectPtr = ImageProcess.initColorCorrection(byteArray) } OpenCVManager.invokeCV(image, action = { byteArray -> manager.recordColorCorrection(operation = "colorCorrection", isPush = isPush, colorCorrectionSettings = colorCorrectionSettings) ImageProcess.colorCorrection(byteArray, colorCorrectionSettings, cppObjectPtr) }, success = { success.invoke(it) }, failure = { e -> logger.error("colorCorrection is failed", e) }) } } /** * 保存图像 */ fun save(state: ApplicationState, action: Action) { val imageFormat = state.rawImageFormat if (imageFormat!=null && imageFormat.isRaw()) { state.scope.launchWithLoading { if (!state.nativeFullImageProcessed) { val filePath = state.rawImageFile?.absolutePath!! val nativePtr = state.nativeImageInfo?.nativePtr!! // 获取全尺寸的 raw 图像,更新金字塔对象,完成调色返回预览对象 val previewImage = ImageProcess.decodeRawAndColorCorrection(filePath, nativePtr, colorCorrectionSettings, cppObjectPtr) if (previewImage!=null) { state.addQueue(state.currentImage!!) val image = BufferedImages.toBufferedImage(previewImage.previewImage, previewImage.width, previewImage.height, BufferedImage.TYPE_INT_ARGB) state.currentImage = image state.nativeFullImageProcessed = true state.togglePreviewWindow(false) } } else { val nativePtr = state.nativeImageInfo?.nativePtr!! // 更新金字塔对象,完成调色返回预览对象 val previewImage = ImageProcess.colorCorrectionWithPyramidImage(nativePtr, colorCorrectionSettings, cppObjectPtr) if (previewImage!=null) { state.addQueue(state.currentImage!!) val image = BufferedImages.toBufferedImage(previewImage.previewImage, previewImage.width, previewImage.height, BufferedImage.TYPE_INT_ARGB) state.currentImage = image state.togglePreviewWindow(false) } } } } else if (imageFormat!=null && imageFormat == ImageFormat.HEIC) { state.scope.launchWithLoading { val nativePtr = state.nativeImageInfo?.nativePtr!! // 更新金字塔对象,完成调色返回预览对象 val previewImage = ImageProcess.colorCorrectionWithPyramidImage(nativePtr, colorCorrectionSettings, cppObjectPtr) if (previewImage!=null) { state.addQueue(state.currentImage!!) val image = BufferedImages.toBufferedImage(previewImage.previewImage, previewImage.width, previewImage.height, BufferedImage.TYPE_INT_ARGB) state.currentImage = image state.togglePreviewWindow(false) } } } else { action.invoke() } } fun undo(block: (ColorCorrectionSettings)-> Unit) { val pair = manager.undo() if (pair!=null) { val lastSettings = pair.first.toSettings() updateParams(lastSettings) block.invoke(lastSettings) } } fun redo(block: (ColorCorrectionSettings)-> Unit) { val pair = manager.redo() if (pair!=null) { val lastSettings = pair.first.toSettings() updateParams(lastSettings) block.invoke(lastSettings) } } fun clearAllStatus() { init.set(false) contrast = 255f hue = 180f saturation = 255f lightness = 255f temperature = 255f highlight = 255f shadow = 255f sharpen = 0f corner = 0f colorCorrectionSettings = ColorCorrectionSettings() if (cppObjectPtr !=0L ) { ImageProcess.deleteColorCorrection(cppObjectPtr) cppObjectPtr = 0 } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorcorrection/NaturalLanguageDialog.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorcorrection import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.* import androidx.compose.material.AlertDialog import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.OutlinedTextField import androidx.compose.material.CircularProgressIndicator import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.domain.ColorCorrectionSettings import cn.netdiscovery.monica.llm.DialogSession import cn.netdiscovery.monica.i18n.getCurrentStringResource import cn.netdiscovery.monica.llm.LLMProvider import cn.netdiscovery.monica.llm.rememberLLMServiceManager import cn.netdiscovery.monica.ui.widget.divider import kotlinx.coroutines.* /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorcorrection.NaturalLanguageDialog * @author: Tony Shen * @date: 2025/8/4 14:00 * @version: V1.0 <描述当前版本功能> */ @Composable fun NaturalLanguageDialog( visible: Boolean, session: DialogSession, deepSeekApiKey: String, geminiApiKey: String, onDismissRequest: () -> Unit, onConfirm: (ColorCorrectionSettings) -> Unit ) { val i18nState = getCurrentStringResource() val llmServiceManager = rememberLLMServiceManager() var inputText by remember { mutableStateOf("") } var loading by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } // 记住用户上次选择的 LLM 提供商 var selectedProvider by remember { mutableStateOf(session.lastUsedProvider ?: LLMProvider.DEEPSEEK) } // 当对话框打开时,如果有历史记录,尝试推断上次使用的提供商 LaunchedEffect(visible) { if (visible && session.history.isNotEmpty()) { // 从历史记录中推断上次使用的提供商 // 这里可以根据历史记录的特征来判断,暂时保持默认选择 // 未来可以考虑在 DialogSession 中添加 provider 字段来记录 } } // 检查当前选择的提供商是否有 API Key val hasApiKey = when (selectedProvider) { LLMProvider.DEEPSEEK -> deepSeekApiKey.isNotBlank() LLMProvider.GEMINI -> geminiApiKey.isNotBlank() } if (visible) { AlertDialog( onDismissRequest = onDismissRequest, title = { Text(i18nState.get("natural_language_color_correction")) }, text = { Column(modifier = Modifier.fillMaxWidth().heightIn(min = 200.dp, max = 500.dp)) { // AI 服务提供商选择 Row( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = i18nState.get("ai_provider_selection") + ": ", fontWeight = FontWeight.Medium ) androidx.compose.material.RadioButton( selected = selectedProvider == LLMProvider.DEEPSEEK, onClick = { selectedProvider = LLMProvider.DEEPSEEK } ) Text( text = i18nState.get("ai_provider_deepseek"), modifier = Modifier.padding(end = 16.dp) ) androidx.compose.material.RadioButton( selected = selectedProvider == LLMProvider.GEMINI, onClick = { selectedProvider = LLMProvider.GEMINI } ) Text(text = i18nState.get("ai_provider_gemini")) } // API Key 状态提示 if (!hasApiKey) { Row( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = "⚠️ ", fontSize = 16.sp, color = Color(0xFFFF8C00), // Orange modifier = Modifier.padding(end = 4.dp) ) Text( text = i18nState.get("api_key_required"), fontSize = 12.sp, color = Color(0xFFFF8C00) // Orange ) } } // 上下文对话记录区 if (session.history.isNotEmpty()) { LazyColumn( modifier = Modifier .fillMaxWidth() .weight(1f) .padding(4.dp) ) { items(session.history) { historyItem -> Column(modifier = Modifier.padding(vertical = 4.dp)) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text("👤 ${historyItem.userInstruction}", fontWeight = FontWeight.Bold) Spacer(modifier = Modifier.weight(1f)) // 显示使用的 LLM 提供商 Text( text = when (historyItem.usedProvider) { LLMProvider.DEEPSEEK -> "🤖 ${i18nState.get("ai_provider_deepseek")}" LLMProvider.GEMINI -> "🤖 ${i18nState.get("ai_provider_gemini")}" }, fontSize = 11.sp, color = Color.Gray ) } Text( i18nState.get("update_parameters") + formatSettingsDiff(historyItem.resultSettings, i18nState), fontSize = 13.sp ) } } } divider() } // 输入框 OutlinedTextField( value = inputText, onValueChange = { inputText = it }, label = { Text(i18nState.get("enter_color_instruction")) }, singleLine = false, maxLines = 4, modifier = Modifier.fillMaxWidth() ) // 进度与错误提示 if (loading) { Spacer(modifier = Modifier.height(10.dp)) CircularProgressIndicator(modifier = Modifier.size(24.dp)) } errorMessage?.let { Spacer(modifier = Modifier.height(10.dp)) Text(it, color = Color.Red) } } }, confirmButton = { TextButton( onClick = { loading = true errorMessage = null CoroutineScope(Dispatchers.IO).launch { try { val apiKey = when (selectedProvider) { LLMProvider.DEEPSEEK -> deepSeekApiKey LLMProvider.GEMINI -> geminiApiKey } // 检查 API Key 是否已配置 if (apiKey.isBlank()) { withContext(Dispatchers.Main) { errorMessage = when (selectedProvider) { LLMProvider.DEEPSEEK -> i18nState.get("deepseek_api_key_missing") LLMProvider.GEMINI -> i18nState.get("gemini_api_key_missing") } } return@launch } val updated = llmServiceManager.applyInstructionWithLLM( provider = selectedProvider, session = session, instruction = inputText, apiKey = apiKey ) if (updated!=null) { // 记录本次使用的 LLM 提供商 session.lastUsedProvider = selectedProvider onConfirm.invoke(updated) onDismissRequest() inputText = "" } } catch (e: Exception) { e.printStackTrace() withContext(Dispatchers.Main) { errorMessage = i18nState.get("request_failed") + (e.message ?: i18nState.get("unknown_error")) } } finally { loading = false } } }, enabled = inputText.isNotBlank() && !loading && hasApiKey ) { Text(i18nState.get("confirm")) } }, dismissButton = { TextButton(onClick = onDismissRequest) { Text(i18nState.get("cancel")) } } ) } } @Composable private fun formatSettingsDiff(settings: ColorCorrectionSettings, i18nState: cn.netdiscovery.monica.i18n.StringResource): String { val list = mutableListOf() if (settings.status == 1) list.add("${i18nState.get("contrast")} → ${settings.contrast}") if (settings.status == 2) list.add("${i18nState.get("hue")} → ${settings.hue}") if (settings.status == 3) list.add("${i18nState.get("saturation")} → ${settings.saturation}") if (settings.status == 4) list.add("${i18nState.get("lightness")} → ${settings.lightness}") if (settings.status == 5) list.add("${i18nState.get("temperature")} → ${settings.temperature}") if (settings.status == 6) list.add("${i18nState.get("highlight")} → ${settings.highlight}") if (settings.status == 7) list.add("${i18nState.get("shadow")} → ${settings.shadow}") if (settings.status == 8) list.add("${i18nState.get("sharpen")} → ${settings.sharpen}") if (settings.status == 9) list.add("${i18nState.get("corner")} → ${settings.corner}") return if (list.isEmpty()) i18nState.get("no_significant_changes") else list.joinToString() } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorpick/ColorPickView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorpick import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.colorpick.model.ColorData import cn.netdiscovery.monica.ui.controlpanel.colorpick.model.ColorNameParser import cn.netdiscovery.monica.ui.controlpanel.colorpick.widget.ColorDisplay import cn.netdiscovery.monica.ui.controlpanel.colorpick.widget.ImageColorDetector import cn.netdiscovery.monica.ui.widget.image.ImageSizeCalculator /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorpick.ColorPickView * @author: Tony Shen * @date: 2024/6/13 16:29 * @version: V1.0 <描述当前版本功能> */ val colorNameParser = ColorNameParser() val defaultThumbnailSize = 150.dp typealias OnColorChange = (ColorData) -> Unit @Composable fun colorPick(state: ApplicationState) { var colorData by remember { mutableStateOf(ColorData(color = Color.Unspecified, name = "")) } // 安全获取图片,避免空指针异常 val image = state.currentImage?.toComposeImageBitmap() // 如果图片为空,显示提示信息 if (image == null) { Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = "请先加载图片", color = androidx.compose.ui.graphics.Color.Gray ) } return } Box( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .background( brush = Brush.verticalGradient( colors = listOf( MaterialTheme.colors.background, MaterialTheme.colors.surface ) ) ), contentAlignment = Alignment.Center ) { // 使用统一的图片尺寸计算 val (width, height) = ImageSizeCalculator.calculateImageSize(state) ImageColorDetector( modifier = Modifier .width(width) .height(height), contentScale = ContentScale.Fit, colorNameParser = colorNameParser, imageBitmap = image, thumbnailSize = defaultThumbnailSize ) { colorData = it } if (colorData.color != Color.Unspecified) { ColorDisplay( modifier = Modifier.align(Alignment.CenterEnd).padding(end = 20.dp), colorData = colorData ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorpick/model/ColorData.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorpick.model import androidx.compose.ui.graphics.Color import cn.netdiscovery.monica.ui.controlpanel.colorpick.utils.* import kotlin.math.roundToInt /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorpick.model.ColorData * @author: Tony Shen * @date: 2024/6/13 20:27 * @version: V1.0 <描述当前版本功能> */ data class ColorData(val color: Color, val name: String) { val hexText: String get() = color.toHex() val hslString: String get() { val arr: FloatArray = color.toHSL() return try { "H: ${arr[0].roundToInt()}° " + "S: ${arr[1].fractionToIntPercent()}% L: ${arr[2].fractionToIntPercent()}%" } catch (e:Exception) { "" } } val hsvString: String get() { val arr: FloatArray = color.toHSV() return try { "H: ${arr[0].roundToInt()}° " + "S: ${arr[1].fractionToIntPercent()}% V: ${arr[2].fractionToIntPercent()}%" } catch (e:Exception) { "" } } val rgb: String get() { val rgb = color.toRGBArray() return "R: ${rgb[0]}, G: ${rgb[1]}, B: ${rgb[2]}" } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorpick/model/ColorNameMap.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorpick.model /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorpick.ColorNameMap * @author: Tony Shen * @date: 2024/6/13 17:08 * @version: V1.0 <描述当前版本功能> */ val colorNameMap by lazy { mapOf( "#FF000000" to "Black", "#FF000080" to "Navy Blue", "#FF0000C8" to "Dark Blue", "#FF0000FF" to "Blue", "#FF000741" to "Stratos", "#FF001B1C" to "Swamp", "#FF002387" to "Resolution Blue", "#FF002900" to "Deep Fir", "#FF002E20" to "Burnham", "#FF002FA7" to "Klein Blue", "#FF003153" to "Prussian Blue", "#FF003366" to "Midnight Blue", "#FF003399" to "Smalt", "#FF003532" to "Deep Teal", "#FF003E40" to "Cyprus", "#FF004620" to "Kaitoke Green", "#FF0047AB" to "Cobalt", "#FF004816" to "Crusoe", "#FF004950" to "Sherpa Blue", "#FF0056A7" to "Endeavour", "#FF00581A" to "Camarone", "#FF0066CC" to "Science Blue", "#FF0066FF" to "Blue Ribbon", "#FF00755E" to "Tropical Rain Forest", "#FF0076A3" to "Allports", "#FF007BA7" to "Deep Cerulean", "#FF007EC7" to "Lochmara", "#FF007FFF" to "Azure Radiance", "#FF008080" to "Teal", "#FF0095B6" to "Bondi Blue", "#FF009DC4" to "Pacific Blue", "#FF00A693" to "Persian Green", "#FF00A86B" to "Jade", "#FF00CC99" to "Caribbean Green", "#FF00CCCC" to "Robin's Egg Blue", "#FF00FF00" to "Green", "#FF00FF7F" to "Spring Green", "#FF00FFFF" to "Cyan Aqua", "#FF010D1A" to "Blue Charcoal", "#FF011635" to "Midnight", "#FF011D13" to "Holly", "#FF012731" to "Daintree", "#FF01361C" to "Cardin Green", "#FF01371A" to "County Green", "#FF013E62" to "Astronaut Blue", "#FF013F6A" to "Regal Blue", "#FF014B43" to "Aqua Deep", "#FF015E85" to "Orient", "#FF016162" to "Blue Stone", "#FF016D39" to "Fun Green", "#FF01796F" to "Pine Green", "#FF017987" to "Blue Lagoon", "#FF01826B" to "Deep Sea", "#FF01A368" to "Green Haze", "#FF022D15" to "English Holly", "#FF02402C" to "Sherwood Green", "#FF02478E" to "Congress Blue", "#FF024E46" to "Evening Sea", "#FF026395" to "Bahama Blue", "#FF02866F" to "Observatory", "#FF02A4D3" to "Cerulean", "#FF03163C" to "Tangaroa", "#FF032B52" to "Green Vogue", "#FF036A6E" to "Mosque", "#FF041004" to "Midnight Moss", "#FF041322" to "Black Pearl", "#FF042E4C" to "Blue Whale", "#FF044022" to "Zuccini", "#FF044259" to "Teal Blue", "#FF051040" to "Deep Cove", "#FF051657" to "Gulf Blue", "#FF055989" to "Venice Blue", "#FF056F57" to "Watercourse", "#FF062A78" to "Catalina Blue", "#FF063537" to "Tiber", "#FF069B81" to "Gossamer", "#FF06A189" to "Niagara", "#FF073A50" to "Tarawera", "#FF080110" to "Jaguar", "#FF081910" to "Black Bean", "#FF082567" to "Deep Sapphire", "#FF088370" to "Elf Green", "#FF08E8DE" to "Bright Turquoise", "#FF092256" to "Downriver", "#FF09230F" to "Palm Green", "#FF09255D" to "Madison", "#FF093624" to "Bottle Green", "#FF095859" to "Deep Sea Green", "#FF097F4B" to "Salem", "#FF0A001C" to "Black Russian", "#FF0A480D" to "Dark Fern", "#FF0A6906" to "Japanese Laurel", "#FF0A6F75" to "Atoll", "#FF0B0B0B" to "Cod Gray", "#FF0B0F08" to "Marshland", "#FF0B1107" to "Gordons Green", "#FF0B1304" to "Black Forest", "#FF0B6207" to "San Felix", "#FF0BDA51" to "Malachite", "#FF0C0B1D" to "Ebony", "#FF0C0D0F" to "Woodsmoke", "#FF0C1911" to "Racing Green", "#FF0C7A79" to "Surfie Green", "#FF0C8990" to "Blue Chill", "#FF0D0332" to "Black Rock", "#FF0D1117" to "Bunker", "#FF0D1C19" to "Aztec", "#FF0D2E1C" to "Bush", "#FF0E0E18" to "Cinder", "#FF0E2A30" to "Firefly", "#FF0F2D9E" to "Torea Bay", "#FF10121D" to "Vulcan", "#FF101405" to "Green Waterloo", "#FF105852" to "Eden", "#FF110C6C" to "Arapawa", "#FF120A8F" to "Ultramarine", "#FF123447" to "Elephant", "#FF126B40" to "Jewel", "#FF130000" to "Diesel", "#FF130A06" to "Asphalt", "#FF13264D" to "Blue Zodiac", "#FF134F19" to "Parsley", "#FF140600" to "Nero", "#FF1450AA" to "Tory Blue", "#FF151F4C" to "Bunting", "#FF1560BD" to "Denim", "#FF15736B" to "Genoa", "#FF161928" to "Mirage", "#FF161D10" to "Hunter Green", "#FF162A40" to "Big Stone", "#FF163222" to "Celtic", "#FF16322C" to "Timber Green", "#FF163531" to "Gable Green", "#FF171F04" to "Pine Tree", "#FF175579" to "Chathams Blue", "#FF182D09" to "Deep Forest Green", "#FF18587A" to "Blumine", "#FF19330E" to "Palm Leaf", "#FF193751" to "Nile Blue", "#FF1959A8" to "Fun Blue", "#FF1A1A68" to "Lucky Point", "#FF1AB385" to "Mountain Meadow", "#FF1B0245" to "Tolopea", "#FF1B1035" to "Haiti", "#FF1B127B" to "Deep Koamaru", "#FF1B1404" to "Acadia", "#FF1B2F11" to "Seaweed", "#FF1B3162" to "Biscay", "#FF1B659D" to "Matisse", "#FF1C1208" to "Crowshead", "#FF1C1E13" to "Rangoon Green", "#FF1C39BB" to "Persian Blue", "#FF1C402E" to "Everglade", "#FF1C7C7D" to "Elm", "#FF1D6142" to "Green Pea", "#FF1E0F04" to "Creole", "#FF1E1609" to "Karaka", "#FF1E1708" to "El Paso", "#FF1E385B" to "Cello", "#FF1E433C" to "Te Papa Green", "#FF1E90FF" to "Dodger Blue", "#FF1E9AB0" to "Eastern Blue", "#FF1F120F" to "Night Rider", "#FF1FC2C2" to "Java", "#FF20208D" to "Jacksons Purple", "#FF202E54" to "Cloud Burst", "#FF204852" to "Blue Dianne", "#FF211A0E" to "Eternity", "#FF220878" to "Deep Blue", "#FF228B22" to "Forest Green", "#FF233418" to "Mallard", "#FF240A40" to "Violet", "#FF240C02" to "Kilimanjaro", "#FF242A1D" to "Log Cabin", "#FF242E16" to "Black Olive", "#FF24500F" to "Green House", "#FF251607" to "Graphite", "#FF251706" to "Cannon Black", "#FF251F4F" to "Port Gore", "#FF25272C" to "Shark", "#FF25311C" to "Green Kelp", "#FF2596D1" to "Curious Blue", "#FF260368" to "Paua", "#FF26056A" to "Paris M", "#FF261105" to "Wood Bark", "#FF261414" to "Gondola", "#FF262335" to "Steel Gray", "#FF26283B" to "Ebony Clay", "#FF273A81" to "Bay of Many", "#FF27504B" to "Plantation", "#FF278A5B" to "Eucalyptus", "#FF281E15" to "Oil", "#FF283A77" to "Astronaut", "#FF286ACD" to "Mariner", "#FF290C5E" to "Violent Violet", "#FF292130" to "Bastille", "#FF292319" to "Zeus", "#FF292937" to "Charade", "#FF297B9A" to "Jelly Bean", "#FF29AB87" to "Jungle Green", "#FF2A0359" to "Cherry Pie", "#FF2A140E" to "Coffee Bean", "#FF2A2630" to "Baltic Sea", "#FF2A380B" to "Turtle Green", "#FF2A52BE" to "Cerulean Blue", "#FF2B0202" to "Sepia Black", "#FF2B194F" to "Valhalla", "#FF2B3228" to "Heavy Metal", "#FF2C0E8C" to "Blue Gem", "#FF2C1632" to "Revolver", "#FF2C2133" to "Bleached Cedar", "#FF2C8C84" to "Lochinvar", "#FF2D2510" to "Mikado", "#FF2D383A" to "Outer Space", "#FF2D569B" to "St Tropez", "#FF2E0329" to "Jacaranda", "#FF2E1905" to "Jacko Bean", "#FF2E3222" to "Rangitoto", "#FF2E3F62" to "Rhino", "#FF2E8B57" to "Sea Green", "#FF2EBFD4" to "Scooter", "#FF2F270E" to "Onion", "#FF2F3CB3" to "Governor Bay", "#FF2F519E" to "Sapphire", "#FF2F5A57" to "Spectra", "#FF2F6168" to "Casal", "#FF300529" to "Melanzane", "#FF301F1E" to "Cocoa Brown", "#FF302A0F" to "Woodrush", "#FF304B6A" to "San Juan", "#FF30D5C8" to "Turquoise", "#FF311C17" to "Eclipse", "#FF314459" to "Pickled Bluewood", "#FF315BA1" to "Azure", "#FF31728D" to "Calypso", "#FF317D82" to "Paradiso", "#FF32127A" to "Persian Indigo", "#FF32293A" to "Blackcurrant", "#FF323232" to "Mine Shaft", "#FF325D52" to "Stromboli", "#FF327C14" to "Bilbao", "#FF327DA0" to "Astral", "#FF33036B" to "Christalle", "#FF33292F" to "Thunder", "#FF33CC99" to "Shamrock", "#FF341515" to "Tamarind", "#FF350036" to "Mardi Gras", "#FF350E42" to "Valentino", "#FF350E57" to "Jagger", "#FF353542" to "Tuna", "#FF354E8C" to "Chambray", "#FF363050" to "Martinique", "#FF363534" to "Tuatara", "#FF363C0D" to "Waiouru", "#FF36747D" to "Ming", "#FF368716" to "La Palma", "#FF370202" to "Chocolate", "#FF371D09" to "Clinker", "#FF37290E" to "Brown Tumbleweed", "#FF373021" to "Birch", "#FF377475" to "Oracle", "#FF380474" to "Blue Diamond", "#FF381A51" to "Grape", "#FF383533" to "Dune", "#FF384555" to "Oxford Blue", "#FF384910" to "Clover", "#FF394851" to "Limed Spruce", "#FF396413" to "Dell", "#FF3A0020" to "Toledo", "#FF3A2010" to "Sambuca", "#FF3A2A6A" to "Jacarta", "#FF3A686C" to "William", "#FF3A6A47" to "Killarney", "#FF3AB09E" to "Keppel", "#FF3B000B" to "Temptress", "#FF3B0910" to "Aubergine", "#FF3B1F1F" to "Jon", "#FF3B2820" to "Treehouse", "#FF3B7A57" to "Amazon", "#FF3B91B4" to "Boston Blue", "#FF3C0878" to "Windsor", "#FF3C1206" to "Rebel", "#FF3C1F76" to "Meteorite", "#FF3C2005" to "Dark Ebony", "#FF3C3910" to "Camouflage", "#FF3C4151" to "Bright Gray", "#FF3C4443" to "Cape Cod", "#FF3C493A" to "Lunar Green", "#FF3D0C02" to "Bean ", "#FF3D2B1F" to "Bistre", "#FF3D7D52" to "Goblin", "#FF3E0480" to "Kingfisher Daisy", "#FF3E1C14" to "Cedar", "#FF3E2B23" to "English Walnut", "#FF3E2C1C" to "Black Marlin", "#FF3E3A44" to "Ship Gray", "#FF3EABBF" to "Pelorous", "#FF3F2109" to "Bronze", "#FF3F2500" to "Cola", "#FF3F3002" to "Madras", "#FF3F307F" to "Minsk", "#FF3F4C3A" to "Cabbage Pont", "#FF3F583B" to "Tom Thumb", "#FF3F5D53" to "Mineral Green", "#FF3FC1AA" to "Puerto Rico", "#FF3FFF00" to "Harlequin", "#FF401801" to "Brown Pod", "#FF40291D" to "Cork", "#FF403B38" to "Masala", "#FF403D19" to "Thatch Green", "#FF405169" to "Fjord", "#FF40826D" to "Viridian", "#FF40A860" to "Chateau Green", "#FF410056" to "Ripe Plum", "#FF411E10" to "Paco", "#FF412010" to "Deep Oak", "#FF413C37" to "Merlin", "#FF414257" to "Gun Powder", "#FF414C7D" to "East Bay", "#FF4169E1" to "Royal Blue", "#FF41AA78" to "Ocean Green", "#FF420303" to "Burnt Maroon", "#FF423921" to "Lisbon Brown", "#FF427977" to "Faded Jade", "#FF431560" to "Scarlet Gum", "#FF433120" to "Iroko", "#FF433E37" to "Armadillo", "#FF434C59" to "River Bed", "#FF436A0D" to "Green Leaf", "#FF44012D" to "Barossa", "#FF441D00" to "Morocco Brown", "#FF444954" to "Mako", "#FF454936" to "Kelp", "#FF456CAC" to "San Marino", "#FF45B1E8" to "Picton Blue", "#FF460B41" to "Loulou", "#FF462425" to "Crater Brown", "#FF465945" to "Gray Asparagus", "#FF4682B4" to "Steel Blue", "#FF480404" to "Rustic Red", "#FF480607" to "Bulgarian Rose", "#FF480656" to "Clairvoyant", "#FF481C1C" to "Cocoa Bean", "#FF483131" to "Woody Brown", "#FF483C32" to "Taupe", "#FF49170C" to "Van Cleef", "#FF492615" to "Brown Derby", "#FF49371B" to "Metallic Bronze", "#FF495400" to "Verdun Green", "#FF496679" to "Blue Bayoux", "#FF497183" to "Bismark", "#FF4A2A04" to "Bracken", "#FF4A3004" to "Deep Bronze", "#FF4A3C30" to "Mondo", "#FF4A4244" to "Tundora", "#FF4A444B" to "Gravel", "#FF4A4E5A" to "Trout", "#FF4B0082" to "Pigment Indigo", "#FF4B5D52" to "Nandor", "#FF4C3024" to "Saddle", "#FF4C4F56" to "Abbey", "#FF4D0135" to "Blackberry", "#FF4D0A18" to "Cab Sav", "#FF4D1E01" to "Indian Tan", "#FF4D282D" to "Cowboy", "#FF4D282E" to "Livid Brown", "#FF4D3833" to "Rock", "#FF4D3D14" to "Punga", "#FF4D400F" to "Bronzetone", "#FF4D5328" to "Woodland", "#FF4E0606" to "Mahogany", "#FF4E2A5A" to "Bossanova", "#FF4E3B41" to "Matterhorn", "#FF4E420C" to "Bronze Olive", "#FF4E4562" to "Mulled Wine", "#FF4E6649" to "Axolotl", "#FF4E7F9E" to "Wedgewood", "#FF4EABD1" to "Shakespeare", "#FF4F1C70" to "Honey Flower", "#FF4F2398" to "Daisy Bush", "#FF4F69C6" to "Indigo", "#FF4F7942" to "Fern Green", "#FF4F9D5D" to "Fruit Salad", "#FF4FA83D" to "Apple", "#FF504351" to "Mortar", "#FF507096" to "Kashmir Blue", "#FF507672" to "Cutty Sark", "#FF50C878" to "Emerald", "#FF514649" to "Emperor", "#FF516E3D" to "Chalet Green", "#FF517C66" to "Como", "#FF51808F" to "Smalt Blue", "#FF52001F" to "Castro", "#FF520C17" to "Maroon Oak", "#FF523C94" to "Gigas", "#FF533455" to "Voodoo", "#FF534491" to "Victoria", "#FF53824B" to "Hippie Green", "#FF541012" to "Heath", "#FF544333" to "Judge Gray", "#FF54534D" to "Fuscous Gray", "#FF549019" to "Vida Loca", "#FF55280C" to "Cioccolato", "#FF555B10" to "Saratoga", "#FF556D56" to "Finlandia", "#FF5590D9" to "Havelock Blue", "#FF56B4BE" to "Fountain Blue", "#FF578363" to "Spring Leaves", "#FF583401" to "Saddle Brown", "#FF585562" to "Scarpa Flow", "#FF587156" to "Cactus", "#FF589AAF" to "Hippie Blue", "#FF591D35" to "Wine Berry", "#FF592804" to "Brown Bramble", "#FF593737" to "Congo Brown", "#FF594433" to "Millbrook", "#FF5A6E9C" to "Waikawa Gray", "#FF5A87A0" to "Horizon", "#FF5B3013" to "Jambalaya", "#FF5C0120" to "Bordeaux", "#FF5C0536" to "Mulberry Wood", "#FF5C2E01" to "Carnaby Tan", "#FF5C5D75" to "Comet", "#FF5D1E0F" to "Redwood", "#FF5D4C51" to "Don Juan", "#FF5D5C58" to "Chicago", "#FF5D5E37" to "Verdigris", "#FF5D7747" to "Dingley", "#FF5DA19F" to "Breaker Bay", "#FF5E483E" to "Kabul", "#FF5E5D3B" to "Hemlock", "#FF5F3D26" to "Irish Coffee", "#FF5F5F6E" to "Mid Gray", "#FF5F6672" to "Shuttle Gray", "#FF5FA777" to "Aqua Forest", "#FF5FB3AC" to "Tradewind", "#FF604913" to "Horses Neck", "#FF605B73" to "Smoky", "#FF606E68" to "Corduroy", "#FF6093D1" to "Danube", "#FF612718" to "Espresso", "#FF614051" to "Eggplant", "#FF615D30" to "Costa Del Sol", "#FF61845F" to "Glade Green", "#FF622F30" to "Buccaneer", "#FF623F2D" to "Quincy", "#FF624E9A" to "Butterfly Bush", "#FF625119" to "West Coast", "#FF626649" to "Finch", "#FF639A8F" to "Patina", "#FF63B76C" to "Fern", "#FF6456B7" to "Blue Violet", "#FF646077" to "Dolphin", "#FF646463" to "Storm Dust", "#FF646A54" to "Siam", "#FF646E75" to "Nevada", "#FF6495ED" to "Cornflower Blue", "#FF64CCDB" to "Viking", "#FF65000B" to "Rosewood", "#FF651A14" to "Cherrywood", "#FF652DC1" to "Purple Heart", "#FF657220" to "Fern Frond", "#FF65745D" to "Willow Grove", "#FF65869F" to "Hoki", "#FF660045" to "Pompadour", "#FF660099" to "Purple", "#FF66023C" to "Tyrian Purple", "#FF661010" to "Dark Tan", "#FF66B58F" to "Silver Tree", "#FF66FF00" to "Bright Green", "#FF66FF66" to "Screamin Green", "#FF67032D" to "Black Rose", "#FF675FA6" to "Scampi", "#FF676662" to "Ironside Gray", "#FF678975" to "Viridian Green", "#FF67A712" to "Christi", "#FF683600" to "Nutmeg Wood Finish", "#FF685558" to "Zambezi", "#FF685E6E" to "Salt Box", "#FF692545" to "Tawny Port", "#FF692D54" to "Finn", "#FF695F62" to "Scorpion", "#FF697E9A" to "Lynch", "#FF6A442E" to "Spice", "#FF6A5D1B" to "Himalaya", "#FF6A6051" to "Soya Bean", "#FF6B2A14" to "Hairy Heath", "#FF6B3FA0" to "Royal Purple", "#FF6B4E31" to "Shingle Fawn", "#FF6B5755" to "Dorado", "#FF6B8BA2" to "Bermuda Gray", "#FF6B8E23" to "Olive Drab", "#FF6C3082" to "Eminence", "#FF6CDAE7" to "Turquoise Blue", "#FF6D0101" to "Lonestar", "#FF6D5E54" to "Pine Cone", "#FF6D6C6C" to "Dove Gray", "#FF6D9292" to "Juniper", "#FF6D92A1" to "Gothic", "#FF6E0902" to "Red Oxide", "#FF6E1D14" to "Moccaccino", "#FF6E4826" to "Pickled Bean", "#FF6E4B26" to "Dallas", "#FF6E6D57" to "Kokoda", "#FF6E7783" to "Pale Sky", "#FF6F440C" to "Cafe Royale", "#FF6F6A61" to "Flint", "#FF6F8E63" to "Highland", "#FF6F9D02" to "Limeade", "#FF6FD0C5" to "Downy", "#FF701C1C" to "Persian Plum", "#FF704214" to "Sepia", "#FF704A07" to "Antique Bronze", "#FF704F50" to "Ferra", "#FF706555" to "Coffee", "#FF708090" to "Slate Gray", "#FF711A00" to "Cedar Wood Finish", "#FF71291D" to "Metallic Copper", "#FF714693" to "Affair", "#FF714AB2" to "Studio", "#FF715D47" to "Tobacco Brown", "#FF716338" to "Yellow Metal", "#FF716B56" to "Peat", "#FF716E10" to "Olivetone", "#FF717486" to "Storm Gray", "#FF718080" to "Sirocco", "#FF71D9E2" to "Aquamarine Blue", "#FF72010F" to "Venetian Red", "#FF724A2F" to "Old Copper", "#FF726D4E" to "Go Ben", "#FF727B89" to "Raven", "#FF731E8F" to "Seance", "#FF734A12" to "Raw Umber", "#FF736C9F" to "Kimberly", "#FF736D58" to "Crocodile", "#FF737829" to "Crete", "#FF738678" to "Xanadu", "#FF74640D" to "Spicy Mustard", "#FF747D63" to "Limed Ash", "#FF747D83" to "Rolling Stone", "#FF748881" to "Blue Smoke", "#FF749378" to "Laurel", "#FF74C365" to "Mantis", "#FF755A57" to "Russett", "#FF7563A8" to "Deluge", "#FF76395D" to "Cosmic", "#FF7666C6" to "Blue Marguerite", "#FF76BD17" to "Lima", "#FF76D7EA" to "Sky Blue", "#FF770F05" to "Dark Burgundy", "#FF771F1F" to "Crown of Thorns", "#FF773F1A" to "Walnut", "#FF776F61" to "Pablo", "#FF778120" to "Pacifika", "#FF779E86" to "Oxley", "#FF77DD77" to "Pastel Green", "#FF780109" to "Japanese Maple", "#FF782D19" to "Mocha", "#FF782F16" to "Peanut", "#FF78866B" to "Camouflage Green", "#FF788A25" to "Wasabi", "#FF788BBA" to "Ship Cove", "#FF78A39C" to "Sea Nymph", "#FF795D4C" to "Roman Coffee", "#FF796878" to "Old Lavender", "#FF796989" to "Rum", "#FF796A78" to "Fedora", "#FF796D62" to "Sandstone", "#FF79DEEC" to "Spray", "#FF7A013A" to "Siren", "#FF7A58C1" to "Fuchsia Blue", "#FF7A7A7A" to "Boulder", "#FF7A89B8" to "Wild Blue Yonder", "#FF7AC488" to "De York", "#FF7B3801" to "Red Beech", "#FF7B3F00" to "Cinnamon", "#FF7B6608" to "Yukon Gold", "#FF7B7874" to "Tapa", "#FF7B7C94" to "Waterloo ", "#FF7B8265" to "Flax Smoke", "#FF7B9F80" to "Amulet", "#FF7BA05B" to "Asparagus", "#FF7C1C05" to "Kenyan Copper", "#FF7C7631" to "Pesto", "#FF7C778A" to "Topaz", "#FF7C7B7A" to "Concord", "#FF7C7B82" to "Jumbo", "#FF7C881A" to "Trendy Green", "#FF7CA1A6" to "Gumbo", "#FF7CB0A1" to "Acapulco", "#FF7CB7BB" to "Neptune", "#FF7D2C14" to "Pueblo", "#FF7DA98D" to "Bay Leaf", "#FF7DC8F7" to "Malibu", "#FF7DD8C6" to "Bermuda", "#FF7E3A15" to "Copper Canyon", "#FF7F1734" to "Claret", "#FF7F3A02" to "Peru Tan", "#FF7F626D" to "Falcon", "#FF7F7589" to "Mobster", "#FF7F76D3" to "Moody Blue", "#FF7FFF00" to "Chartreuse", "#FF7FFFD4" to "Aquamarine", "#FF800000" to "Maroon", "#FF800B47" to "Rose Bud Cherry", "#FF801818" to "Falu Red", "#FF80341F" to "Red Robin", "#FF803790" to "Vivid Violet", "#FF80461B" to "Russet", "#FF807E79" to "Friar Gray", "#FF808000" to "Olive", "#FF808080" to "Gray", "#FF80B3AE" to "Gulf Stream", "#FF80B3C4" to "Glacier", "#FF80CCEA" to "Seagull", "#FF81422C" to "Nutmeg", "#FF816E71" to "Spicy Pink", "#FF817377" to "Empress", "#FF819885" to "Spanish Green", "#FF826F65" to "Sand Dune", "#FF828685" to "Gunsmoke", "#FF828F72" to "Battleship Gray", "#FF831923" to "Merlot", "#FF837050" to "Shadow", "#FF83AA5D" to "Chelsea Cucumber", "#FF83D0C6" to "Monte Carlo", "#FF843179" to "Plum", "#FF84A0A0" to "Granny Smith", "#FF8581D9" to "Chetwode Blue", "#FF858470" to "Bandicoot", "#FF859FAF" to "Bali Hai", "#FF85C4CC" to "Half Baked", "#FF860111" to "Red Devil", "#FF863C3C" to "Lotus", "#FF86483C" to "Ironstone", "#FF864D1E" to "Bull Shot", "#FF86560A" to "Rusty Nail", "#FF868974" to "Bitter", "#FF86949F" to "Regent Gray", "#FF871550" to "Disco", "#FF87756E" to "Americano", "#FF877C7B" to "Hurricane", "#FF878D91" to "Oslo Gray", "#FF87AB39" to "Sushi", "#FF885342" to "Spicy Mix", "#FF886221" to "Kumera", "#FF888387" to "Suva Gray", "#FF888D65" to "Avocado", "#FF893456" to "Camelot", "#FF893843" to "Solid Pink", "#FF894367" to "Cannon Pink", "#FF897D6D" to "Makara", "#FF8A3324" to "Burnt Umber", "#FF8A73D6" to "True V", "#FF8A8360" to "Clay Creek", "#FF8A8389" to "Monsoon", "#FF8A8F8A" to "Stack", "#FF8AB9F1" to "Jordy Blue", "#FF8B00FF" to "Electric Violet", "#FF8B0723" to "Monarch", "#FF8B6B0B" to "Corn Harvest", "#FF8B8470" to "Olive Haze", "#FF8B847E" to "Schooner", "#FF8B8680" to "Natural Gray", "#FF8B9C90" to "Mantle", "#FF8B9FEE" to "Portage", "#FF8BA690" to "Envy", "#FF8BA9A5" to "Cascade", "#FF8BE6D8" to "Riptide", "#FF8C055E" to "Cardinal Pink", "#FF8C472F" to "Mule Fawn", "#FF8C5738" to "Potters Clay", "#FF8C6495" to "Trendy Pink", "#FF8D0226" to "Paprika", "#FF8D3D38" to "Sanguine Brown", "#FF8D3F3F" to "Tosca", "#FF8D7662" to "Cement", "#FF8D8974" to "Granite Green", "#FF8D90A1" to "Manatee", "#FF8DA8CC" to "Polo Blue", "#FF8E0000" to "Red Berry", "#FF8E4D1E" to "Rope", "#FF8E6F70" to "Opium", "#FF8E775E" to "Domino", "#FF8E8190" to "Mamba", "#FF8EABC1" to "Nepal", "#FF8F021C" to "Pohutukawa", "#FF8F3E33" to "El Salva", "#FF8F4B0E" to "Korma", "#FF8F8176" to "Squirrel", "#FF8FD6B4" to "Vista Blue", "#FF900020" to "Burgundy", "#FF901E1E" to "Old Brick", "#FF907874" to "Hemp", "#FF907B71" to "Almond Frost", "#FF908D39" to "Sycamore", "#FF92000A" to "Sangria", "#FF924321" to "Cumin", "#FF926F5B" to "Beaver", "#FF928573" to "Stonewall", "#FF928590" to "Venus", "#FF9370DB" to "Medium Purple", "#FF93CCEA" to "Cornflower", "#FF93DFB8" to "Algae Green", "#FF944747" to "Copper Rust", "#FF948771" to "Arrowtown", "#FF950015" to "Scarlett", "#FF956387" to "Strikemaster", "#FF959396" to "Mountain Mist", "#FF960018" to "Carmine", "#FF964B00" to "Brown", "#FF967059" to "Leather", "#FF9678B6" to "Purple Mountain", "#FF967BB6" to "Lavender Purple", "#FF96A8A1" to "Pewter", "#FF96BBAB" to "Summer Green", "#FF97605D" to "Au Chico", "#FF9771B5" to "Wisteria", "#FF97CD2D" to "Atlantis", "#FF983D61" to "Vin Rouge", "#FF9874D3" to "Lilac Bush", "#FF98777B" to "Bazaar", "#FF98811B" to "Hacienda", "#FF988D77" to "Pale Oyster", "#FF98FF98" to "Mint Green", "#FF990066" to "Fresh Eggplant", "#FF991199" to "Violet Eggplant", "#FF991613" to "Tamarillo", "#FF991B07" to "Totem Pole", "#FF996666" to "Copper Rose", "#FF9966CC" to "Amethyst", "#FF997A8D" to "Mountbatten Pink", "#FF9999CC" to "Blue Bell", "#FF9A3820" to "Prairie Sand", "#FF9A6E61" to "Toast", "#FF9A9577" to "Gurkha", "#FF9AB973" to "Olivine", "#FF9AC2B8" to "Shadow Green", "#FF9B4703" to "Oregon", "#FF9B9E8F" to "Lemon Grass", "#FF9C3336" to "Stiletto", "#FF9D5616" to "Hawaiian Tan", "#FF9DACB7" to "Gull Gray", "#FF9DC209" to "Pistachio", "#FF9DE093" to "Granny Smith Apple", "#FF9DE5FF" to "Anakiwa", "#FF9E5302" to "Chelsea Gem", "#FF9E5B40" to "Sepia Skin", "#FF9EA587" to "Sage", "#FF9EA91F" to "Citron", "#FF9EB1CD" to "Rock Blue", "#FF9EDEE0" to "Morning Glory", "#FF9F381D" to "Cognac", "#FF9F821C" to "Reef Gold", "#FF9F9F9C" to "Star Dust", "#FF9FA0B1" to "Santas Gray", "#FF9FD7D3" to "Sinbad", "#FF9FDD8C" to "Feijoa", "#FFA02712" to "Tabasco", "#FFA1750D" to "Buttered Rum", "#FFA1ADB5" to "Hit Gray", "#FFA1C50A" to "Citrus", "#FFA1DAD7" to "Aqua Island", "#FFA1E9DE" to "Water Leaf", "#FFA2006D" to "Flirt", "#FFA23B6C" to "Rouge", "#FFA26645" to "Cape Palliser", "#FFA2AAB3" to "Gray Chateau", "#FFA2AEAB" to "Edward", "#FFA3807B" to "Pharlap", "#FFA397B4" to "Amethyst Smoke", "#FFA3E3ED" to "Blizzard Blue", "#FFA4A49D" to "Delta", "#FFA4A6D3" to "Wistful", "#FFA4AF6E" to "Green Smoke", "#FFA50B5E" to "Jazzberry Jam", "#FFA59B91" to "Zorba", "#FFA5CB0C" to "Bahia", "#FFA62F20" to "Roof Terracotta", "#FFA65529" to "Paarl", "#FFA68B5B" to "Barley Corn", "#FFA69279" to "Donkey Brown", "#FFA6A29A" to "Dawn", "#FFA72525" to "Mexican Red", "#FFA7882C" to "Luxor Gold", "#FFA85307" to "Rich Gold", "#FFA86515" to "Reno Sand", "#FFA86B6B" to "Coral Tree", "#FFA8989B" to "Dusty Gray", "#FFA899E6" to "Dull Lavender", "#FFA8A589" to "Tallow", "#FFA8AE9C" to "Bud", "#FFA8AF8E" to "Locust", "#FFA8BD9F" to "Norway", "#FFA8E3BD" to "Chinook", "#FFA9A491" to "Gray Olive", "#FFA9ACB6" to "Aluminium", "#FFA9B2C3" to "Cadet Blue", "#FFA9B497" to "Schist", "#FFA9BDBF" to "Tower Gray", "#FFA9BEF2" to "Perano", "#FFA9C6C2" to "Opal", "#FFAA375A" to "Night Shadz", "#FFAA4203" to "Fire", "#FFAA8B5B" to "Muesli", "#FFAA8D6F" to "Sandal", "#FFAAA5A9" to "Shady Lady", "#FFAAA9CD" to "Logan", "#FFAAABB7" to "Spun Pearl", "#FFAAD6E6" to "Regent St Blue", "#FFAAF0D1" to "Magic Mint", "#FFAB0563" to "Lipstick", "#FFAB3472" to "Royal Heath", "#FFAB917A" to "Sandrift", "#FFABA0D9" to "Cold Purple", "#FFABA196" to "Bronco", "#FFAC8A56" to "Limed Oak", "#FFAC91CE" to "East Side", "#FFAC9E22" to "Lemon Ginger", "#FFACA494" to "Napa", "#FFACA586" to "Hillary", "#FFACA59F" to "Cloudy", "#FFACACAC" to "Silver Chalice", "#FFACB78E" to "Swamp Green", "#FFACCBB1" to "Spring Rain", "#FFACDD4D" to "Conifer", "#FFACE1AF" to "Celadon", "#FFAD781B" to "Mandalay", "#FFADBED1" to "Casper", "#FFADDFAD" to "Moss Green", "#FFADE6C4" to "Padua", "#FFADFF2F" to "Green Yellow", "#FFAE4560" to "Hippie Pink", "#FFAE6020" to "Desert", "#FFAE809E" to "Bouquet", "#FFAF4035" to "Medium Carmine", "#FFAF4D43" to "Apple Blossom", "#FFAF593E" to "Brown Rust", "#FFAF8751" to "Driftwood", "#FFAF8F2C" to "Alpine", "#FFAF9F1C" to "Lucky", "#FFAFA09E" to "Martini", "#FFAFB1B8" to "Bombay", "#FFAFBDD9" to "Pigeon Post", "#FFB04C6A" to "Cadillac", "#FFB05D54" to "Matrix", "#FFB05E81" to "Tapestry", "#FFB06608" to "Mai Tai", "#FFB09A95" to "Del Rio", "#FFB0E0E6" to "Powder Blue", "#FFB0E313" to "Inch Worm", "#FFB10000" to "Bright Red", "#FFB14A0B" to "Vesuvius", "#FFB1610B" to "Pumpkin Skin", "#FFB16D52" to "Santa Fe", "#FFB19461" to "Teak", "#FFB1E2C1" to "Fringy Flower", "#FFB1F4E7" to "Ice Cold", "#FFB20931" to "Shiraz", "#FFB2A1EA" to "Biloba Flower", "#FFB32D29" to "Tall Poppy", "#FFB35213" to "Fiery Orange", "#FFB38007" to "Hot Toddy", "#FFB3AF95" to "Taupe Gray", "#FFB3C110" to "La Rioja", "#FFB43332" to "Well Read", "#FFB44668" to "Blush", "#FFB4CFD3" to "Jungle Mist", "#FFB57281" to "Turkish Rose", "#FFB57EDC" to "Lavender", "#FFB5A27F" to "Mongoose", "#FFB5B35C" to "Olive Green", "#FFB5D2CE" to "Jet Stream", "#FFB5ECDF" to "Cruise", "#FFB6316C" to "Hibiscus", "#FFB69D98" to "Thatch", "#FFB6B095" to "Heathered Gray", "#FFB6BAA4" to "Eagle", "#FFB6D1EA" to "Spindle", "#FFB6D3BF" to "Gum Leaf", "#FFB7410E" to "Rust", "#FFB78E5C" to "Muddy Waters", "#FFB7A214" to "Sahara", "#FFB7A458" to "Husk", "#FFB7B1B1" to "Nobel", "#FFB7C3D0" to "Heather", "#FFB7F0BE" to "Madang", "#FFB81104" to "Milano Red", "#FFB87333" to "Copper", "#FFB8B56A" to "Gimblet", "#FFB8C1B1" to "Green Spring", "#FFB8C25D" to "Celery", "#FFB8E0F9" to "Sail", "#FFB94E48" to "Chestnut", "#FFB95140" to "Crail", "#FFB98D28" to "Marigold", "#FFB9C46A" to "Wild Willow", "#FFB9C8AC" to "Rainee", "#FFBA0101" to "Guardsman Red", "#FFBA450C" to "Rock Spray", "#FFBA6F1E" to "Bourbon", "#FFBA7F03" to "Pirate Gold", "#FFBAB1A2" to "Nomad", "#FFBAC7C9" to "Submarine", "#FFBAEEF9" to "Charlotte", "#FFBB3385" to "Medium Red Violet", "#FFBB8983" to "Brandy Rose", "#FFBBD009" to "Rio Grande", "#FFBBD7C1" to "Surf", "#FFBCC9C2" to "Powder Ash", "#FFBD5E2E" to "Tuscany", "#FFBD978E" to "Quicksand", "#FFBDB1A8" to "Silk", "#FFBDB2A1" to "Malta", "#FFBDB3C7" to "Chatelle", "#FFBDBBD7" to "Lavender Gray", "#FFBDBDC6" to "French Gray", "#FFBDC8B3" to "Clay Ash", "#FFBDC9CE" to "Loblolly", "#FFBDEDFD" to "French Pass", "#FFBEA6C3" to "London Hue", "#FFBEB5B7" to "Pink Swan", "#FFBEDE0D" to "Fuego", "#FFBF5500" to "Rose of Sharon", "#FFBFB8B0" to "Tide", "#FFBFBED8" to "Blue Haze", "#FFBFC1C2" to "Silver Sand", "#FFBFC921" to "Key Lime Pie", "#FFBFDBE2" to "Ziggurat", "#FFBFFF00" to "Lime", "#FFC02B18" to "Thunderbird", "#FFC04737" to "Mojo", "#FFC08081" to "Old Rose", "#FFC0C0C0" to "Silver", "#FFC0D3B9" to "Pale Leaf", "#FFC0D8B6" to "Pixie Green", "#FFC1440E" to "Tia Maria", "#FFC154C1" to "Fuchsia Pink", "#FFC1A004" to "Buddha Gold", "#FFC1B7A4" to "Bison Hide", "#FFC1BAB0" to "Tea", "#FFC1BECD" to "Gray Suit", "#FFC1D7B0" to "Sprout", "#FFC1F07C" to "Sulu", "#FFC26B03" to "Indochine", "#FFC2955D" to "Twine", "#FFC2BDB6" to "Cotton Seed", "#FFC2CAC4" to "Pumice", "#FFC2E8E5" to "Jagged Ice", "#FFC32148" to "Maroon Flush", "#FFC3B091" to "Indian Khaki", "#FFC3BFC1" to "Pale Slate", "#FFC3C3BD" to "Gray Nickel", "#FFC3CDE6" to "Periwinkle Gray", "#FFC3D1D1" to "Tiara", "#FFC3DDF9" to "Tropical Blue", "#FFC41E3A" to "Cardinal", "#FFC45655" to "Fuzzy Wuzzy Brown", "#FFC45719" to "Orange Roughy", "#FFC4C4BC" to "Mist Gray", "#FFC4D0B0" to "Coriander", "#FFC4F4EB" to "Mint Tulip", "#FFC54B8C" to "Mulberry", "#FFC59922" to "Nugget", "#FFC5994B" to "Tussock", "#FFC5DBCA" to "Sea Mist", "#FFC5E17A" to "Yellow Green", "#FFC62D42" to "Brick Red", "#FFC6726B" to "Contessa", "#FFC69191" to "Oriental Pink", "#FFC6A84B" to "Roti", "#FFC6C3B5" to "Ash", "#FFC6C8BD" to "Kangaroo", "#FFC6E610" to "Las Palmas", "#FFC7031E" to "Monza", "#FFC71585" to "Red Violet", "#FFC7BCA2" to "Coral Reef", "#FFC7C1FF" to "Melrose", "#FFC7C4BF" to "Cloud", "#FFC7C9D5" to "Ghost", "#FFC7CD90" to "Pine Glade", "#FFC7DDE5" to "Botticelli", "#FFC88A65" to "Antique Brass", "#FFC8A2C8" to "Lilac", "#FFC8A528" to "Hokey Pokey", "#FFC8AABF" to "Lily", "#FFC8B568" to "Laser", "#FFC8E3D7" to "Edgewater", "#FFC96323" to "Piper", "#FFC99415" to "Pizza", "#FFC9A0DC" to "Light Wisteria", "#FFC9B29B" to "Rodeo Dust", "#FFC9B35B" to "Sundance", "#FFC9B93B" to "Earls Green", "#FFC9C0BB" to "Silver Rust", "#FFC9D9D2" to "Conch", "#FFC9FFA2" to "Reef", "#FFC9FFE5" to "Aero Blue", "#FFCA3435" to "Flush Mahogany", "#FFCABB48" to "Turmeric", "#FFCADCD4" to "Paris White", "#FFCAE00D" to "Bitter Lemon", "#FFCAE6DA" to "Skeptic", "#FFCB8FA9" to "Viola", "#FFCBCAB6" to "Foggy Gray", "#FFCBD3B0" to "Green Mist", "#FFCBDBD6" to "Nebula", "#FFCC3333" to "Persian Red", "#FFCC5501" to "Burnt Orange", "#FFCC7722" to "Ochre", "#FFCC8899" to "Puce", "#FFCCCAA8" to "Thistle Green", "#FFCCCCFF" to "Periwinkle", "#FFCCFF00" to "Electric Lime", "#FFCD5700" to "Tenn", "#FFCD5C5C" to "Chestnut Rose", "#FFCD8429" to "Brandy Punch", "#FFCDF4FF" to "Onahau", "#FFCEB98F" to "Sorrell Brown", "#FFCEBABA" to "Cold Turkey", "#FFCEC291" to "Yuma", "#FFCEC7A7" to "Chino", "#FFCFA39D" to "Eunry", "#FFCFB53B" to "Old Gold", "#FFCFDCCF" to "Tasman", "#FFCFE5D2" to "Surf Crest", "#FFCFF9F3" to "Humming Bird", "#FFCFFAF4" to "Scandal", "#FFD05F04" to "Red Stage", "#FFD06DA1" to "Hopbush", "#FFD07D12" to "Meteor", "#FFD0BEF8" to "Perfume", "#FFD0C0E5" to "Prelude", "#FFD0F0C0" to "Tea Green", "#FFD18F1B" to "Geebung", "#FFD1BEA8" to "Vanilla", "#FFD1C6B4" to "Soft Amber", "#FFD1D2CA" to "Celeste", "#FFD1D2DD" to "Mischka", "#FFD1E231" to "Pear", "#FFD2691E" to "Hot Cinnamon", "#FFD27D46" to "Raw Sienna", "#FFD29EAA" to "Careys Pink", "#FFD2B48C" to "Tan", "#FFD2DA97" to "Deco", "#FFD2F6DE" to "Blue Romance", "#FFD2F8B0" to "Gossip", "#FFD3CBBA" to "Sisal", "#FFD3CDC5" to "Swirl", "#FFD47494" to "Charm", "#FFD4B6AF" to "Clam Shell", "#FFD4BF8D" to "Straw", "#FFD4C4A8" to "Akaroa", "#FFD4CD16" to "Bird Flower", "#FFD4D7D9" to "Iron", "#FFD4DFE2" to "Geyser", "#FFD4E2FC" to "Hawkes Blue", "#FFD54600" to "Grenadier", "#FFD591A4" to "Can Can", "#FFD59A6F" to "Whiskey", "#FFD5D195" to "Winter Hazel", "#FFD5F6E3" to "Granny Apple", "#FFD69188" to "My Pink", "#FFD6C562" to "Tacha", "#FFD6CEF6" to "Moon Raker", "#FFD6D6D1" to "Quill Gray", "#FFD6FFDB" to "Snowy Mint", "#FFD7837F" to "New York Pink", "#FFD7C498" to "Pavlova", "#FFD7D0FF" to "Fog", "#FFD84437" to "Valencia", "#FFD87C63" to "Japonica", "#FFD8BFD8" to "Thistle", "#FFD8C2D5" to "Maverick", "#FFD8FCFA" to "Foam", "#FFD94972" to "Cabaret", "#FFD99376" to "Burning Sand", "#FFD9B99B" to "Cameo", "#FFD9D6CF" to "Timberwolf", "#FFD9DCC1" to "Tana", "#FFD9E4F5" to "Link Water", "#FFD9F7FF" to "Mabel", "#FFDA3287" to "Cerise", "#FFDA5B38" to "Flame Pea", "#FFDA6304" to "Bamboo", "#FFDA6A41" to "Red Damask", "#FFDA70D6" to "Orchid", "#FFDA8A67" to "Copperfield", "#FFDAA520" to "Golden Grass", "#FFDAECD6" to "Zanah", "#FFDAF4F0" to "Iceberg", "#FFDAFAFF" to "Oyster Bay", "#FFDB5079" to "Cranberry", "#FFDB9690" to "Petite Orchid", "#FFDB995E" to "Di Serria", "#FFDBDBDB" to "Alto", "#FFDBFFF8" to "Frosted Mint", "#FFDC143C" to "Crimson", "#FFDC4333" to "Punch", "#FFDCB20C" to "Galliano", "#FFDCB4BC" to "Blossom", "#FFDCD747" to "Wattle", "#FFDCD9D2" to "Westar", "#FFDCDDCC" to "Moon Mist", "#FFDCEDB4" to "Caper", "#FFDCF0EA" to "Swans Down", "#FFDDD6D5" to "Swiss Coffee", "#FFDDF9F1" to "White Ice", "#FFDE3163" to "Cerise Red", "#FFDE6360" to "Roman", "#FFDEA681" to "Tumbleweed", "#FFDEBA13" to "Gold Tips", "#FFDEC196" to "Brandy", "#FFDECBC6" to "Wafer", "#FFDED4A4" to "Sapling", "#FFDED717" to "Barberry", "#FFDEE5C0" to "Beryl Green", "#FFDEF5FF" to "Pattens Blue", "#FFDF73FF" to "Heliotrope", "#FFDFBE6F" to "Apache", "#FFDFCD6F" to "Chenin", "#FFDFCFDB" to "Lola", "#FFDFECDA" to "Willow Brook", "#FFDFFF00" to "Chartreuse Yellow", "#FFE0B0FF" to "Mauve", "#FFE0B646" to "Anzac", "#FFE0B974" to "Harvest Gold", "#FFE0C095" to "Calico", "#FFE0FFFF" to "Baby Blue", "#FFE16865" to "Sunglo", "#FFE1BC64" to "Equator", "#FFE1C0C8" to "Pink Flare", "#FFE1E6D6" to "Periglacial Blue", "#FFE1EAD4" to "Kidnapper", "#FFE1F6E8" to "Tara", "#FFE25465" to "Mandy", "#FFE2725B" to "Terracotta", "#FFE28913" to "Golden Bell", "#FFE292C0" to "Shocking", "#FFE29418" to "Dixie", "#FFE29CD2" to "Light Orchid", "#FFE2D8ED" to "Snuff", "#FFE2EBED" to "Mystic", "#FFE2F3EC" to "Apple Green", "#FFE30B5C" to "Razzmatazz", "#FFE32636" to "Alizarin Crimson", "#FFE34234" to "Cinnabar", "#FFE3BEBE" to "Cavern Pink", "#FFE3F5E1" to "Peppermint", "#FFE3F988" to "Mindaro", "#FFE47698" to "Deep Blush", "#FFE49B0F" to "Gamboge", "#FFE4C2D5" to "Melanie", "#FFE4CFDE" to "Twilight", "#FFE4D1C0" to "Bone", "#FFE4D422" to "Sunflower", "#FFE4D5B7" to "Grain Brown", "#FFE4D69B" to "Zombie", "#FFE4F6E7" to "Frostee", "#FFE4FFD1" to "Snow Flurry", "#FFE52B50" to "Amaranth", "#FFE5841B" to "Zest", "#FFE5CCC9" to "Dust Storm", "#FFE5D7BD" to "Stark White", "#FFE5D8AF" to "Hampton", "#FFE5E0E1" to "Bon Jour", "#FFE5E5E5" to "Mercury", "#FFE5F9F6" to "Polar", "#FFE64E03" to "Trinidad", "#FFE6BE8A" to "Gold Sand", "#FFE6BEA5" to "Cashmere", "#FFE6D7B9" to "Double Spanish White", "#FFE6E4D4" to "Satin Linen", "#FFE6F2EA" to "Harp", "#FFE6F8F3" to "Off Green", "#FFE6FFE9" to "Hint of Green", "#FFE6FFFF" to "Tranquil", "#FFE77200" to "Mango Tango", "#FFE7730A" to "Christine", "#FFE79F8C" to "Tony's Pink", "#FFE79FC4" to "Kobi", "#FFE7BCB4" to "Rose Fog", "#FFE7BF05" to "Corn", "#FFE7CD8C" to "Putty", "#FFE7ECE6" to "Gray Nurse", "#FFE7F8FF" to "Lily White", "#FFE7FEFF" to "Bubbles", "#FFE89928" to "Fire Bush", "#FFE8B9B3" to "Shilo", "#FFE8E0D5" to "Pearl Bush", "#FFE8EBE0" to "Green White", "#FFE8F1D4" to "Chrome White", "#FFE8F2EB" to "Gin", "#FFE8F5F2" to "Aqua Squeeze", "#FFE96E00" to "Clementine", "#FFE97451" to "Burnt Sienna", "#FFE97C07" to "Tahiti Gold", "#FFE9CECD" to "Oyster Pink", "#FFE9D75A" to "Confetti", "#FFE9E3E3" to "Ebb", "#FFE9F8ED" to "Ottoman", "#FFE9FFFD" to "Clear Day", "#FFEA88A8" to "Carissma", "#FFEAAE69" to "Porsche", "#FFEAB33B" to "Tulip Tree", "#FFEAC674" to "Rob Roy", "#FFEADAB8" to "Raffia", "#FFEAE8D4" to "White Rock", "#FFEAF6EE" to "Panache", "#FFEAF6FF" to "Solitude", "#FFEAF9F5" to "Aqua Spring", "#FFEAFFFE" to "Dew", "#FFEB9373" to "Apricot", "#FFEBC2AF" to "Zinnwaldite", "#FFECA927" to "Fuel Yellow", "#FFECC54E" to "Ronchi", "#FFECC7EE" to "French Lilac", "#FFECCDB9" to "Just Right", "#FFECE090" to "Wild Rice", "#FFECEBBD" to "Fall Green", "#FFECEBCE" to "Aths Special", "#FFECF245" to "Starship", "#FFED0A3F" to "Red Ribbon", "#FFED7A1C" to "Tango", "#FFED9121" to "Carrot Orange", "#FFED989E" to "Sea Pink", "#FFEDB381" to "Tacao", "#FFEDC9AF" to "Desert Sand", "#FFEDCDAB" to "Pancho", "#FFEDDCB1" to "Chamois", "#FFEDEA99" to "Primrose", "#FFEDF5DD" to "Frost", "#FFEDF5F5" to "Aqua Haze", "#FFEDF6FF" to "Zumthor", "#FFEDF9F1" to "Narvik", "#FFEDFC84" to "Honeysuckle", "#FFEE82EE" to "Lavender Magenta", "#FFEEC1BE" to "Beauty Bush", "#FFEED794" to "Chalky", "#FFEED9C4" to "Almond", "#FFEEDC82" to "Flax", "#FFEEDEDA" to "Bizarre", "#FFEEE3AD" to "Double Colonial White", "#FFEEEEE8" to "Cararra", "#FFEEEF78" to "Manz", "#FFEEF0C8" to "Tahuna Sands", "#FFEEF0F3" to "Athens Gray", "#FFEEF3C3" to "Tusk", "#FFEEF4DE" to "Loafer", "#FFEEF6F7" to "Catskill White", "#FFEEFDFF" to "Twilight Blue", "#FFEEFF9A" to "Jonquil", "#FFEEFFE2" to "Rice Flower", "#FFEF863F" to "Jaffa", "#FFEFEFEF" to "Gallery", "#FFEFF2F3" to "Porcelain", "#FFF091A9" to "Mauvelous", "#FFF0D52D" to "Golden Dream", "#FFF0DB7D" to "Golden Sand", "#FFF0DC82" to "Buff", "#FFF0E2EC" to "Prim", "#FFF0E68C" to "Khaki", "#FFF0EEFD" to "Selago", "#FFF0EEFF" to "Titan White", "#FFF0F8FF" to "Alice Blue", "#FFF0FCEA" to "Feta", "#FFF18200" to "Gold Drop", "#FFF19BAB" to "Wewak", "#FFF1E788" to "Sahara Sand", "#FFF1E9D2" to "Parchment", "#FFF1E9FF" to "Blue Chalk", "#FFF1EEC1" to "Mint Julep", "#FFF1F1F1" to "Seashell", "#FFF1F7F2" to "Saltpan", "#FFF1FFAD" to "Tidal", "#FFF1FFC8" to "Chiffon", "#FFF2552A" to "Flamingo", "#FFF28500" to "Tangerine", "#FFF2C3B2" to "Mandy's Pink", "#FFF2F2F2" to "Concrete", "#FFF2FAFA" to "Black Squeeze", "#FFF34723" to "Pomegranate", "#FFF3AD16" to "Buttercup", "#FFF3D69D" to "New Orleans", "#FFF3D9DF" to "Vanilla Ice", "#FFF3E7BB" to "Sidecar", "#FFF3E9E5" to "Dawn Pink", "#FFF3EDCF" to "Wheatfield", "#FFF3FB62" to "Canary", "#FFF3FBD4" to "Orinoco", "#FFF3FFD8" to "Carla", "#FFF400A1" to "Hollywood Cerise", "#FFF4A460" to "Sandy brown", "#FFF4C430" to "Saffron", "#FFF4D81C" to "Ripe Lemon", "#FFF4EBD3" to "Janna", "#FFF4F2EE" to "Pampas", "#FFF4F4F4" to "Wild Sand", "#FFF4F8FF" to "Zircon", "#FFF57584" to "Froly", "#FFF5C85C" to "Cream Can", "#FFF5C999" to "Manhattan", "#FFF5D5A0" to "Maize", "#FFF5DEB3" to "Wheat", "#FFF5E7A2" to "Sandwisp", "#FFF5E7E2" to "Pot Pourri", "#FFF5E9D3" to "Albescent White", "#FFF5EDEF" to "Soft Peach", "#FFF5F3E5" to "Ecru White", "#FFF5F5DC" to "Beige", "#FFF5FB3D" to "Golden Fizz", "#FFF5FFBE" to "Australian Mint", "#FFF64A8A" to "French Rose", "#FFF653A6" to "Brilliant Rose", "#FFF6A4C9" to "Illusion", "#FFF6F0E6" to "Merino", "#FFF6F7F7" to "Black Haze", "#FFF6FFDC" to "Spring Sun", "#FFF7468A" to "Violet Red", "#FFF77703" to "Chilean Fire", "#FFF77FBE" to "Persian Pink", "#FFF7B668" to "Rajah", "#FFF7C8DA" to "Azalea", "#FFF7DBE6" to "We Peep", "#FFF7F2E1" to "Quarter Spanish White", "#FFF7F5FA" to "Whisper", "#FFF7FAF7" to "Snow Drift", "#FFF8B853" to "Casablanca", "#FFF8C3DF" to "Chantilly", "#FFF8D9E9" to "Cherub", "#FFF8DB9D" to "Marzipan", "#FFF8DD5C" to "Energy Yellow", "#FFF8E4BF" to "Givry", "#FFF8F0E8" to "White Linen", "#FFF8F4FF" to "Magnolia", "#FFF8F6F1" to "Spring Wood", "#FFF8F7DC" to "Coconut Cream", "#FFF8F7FC" to "White Lilac", "#FFF8F8F7" to "Desert Storm", "#FFF8F99C" to "Texas", "#FFF8FACD" to "Corn Field", "#FFF8FDD3" to "Mimosa", "#FFF95A61" to "Carnation", "#FFF9BF58" to "Saffron Mango", "#FFF9E0ED" to "Carousel Pink", "#FFF9E4BC" to "Dairy Cream", "#FFF9E663" to "Portica", "#FFF9EAF3" to "Amour", "#FFF9F8E4" to "Rum Swizzle", "#FFF9FF8B" to "Dolly", "#FFF9FFF6" to "Sugar Cane", "#FFFA7814" to "Ecstasy", "#FFFA9D5A" to "Tan Hide", "#FFFAD3A2" to "Corvette", "#FFFADFAD" to "Peach Yellow", "#FFFAE600" to "Turbo", "#FFFAEAB9" to "Astra", "#FFFAECCC" to "Champagne", "#FFFAF0E6" to "Linen", "#FFFAF3F0" to "Fantasy", "#FFFAF7D6" to "Citrine White", "#FFFAFAFA" to "Alabaster", "#FFFAFDE4" to "Hint of Yellow", "#FFFAFFA4" to "Milan", "#FFFB607F" to "Brink Pink", "#FFFB8989" to "Geraldine", "#FFFBA0E3" to "Lavender Rose", "#FFFBA129" to "Sea Buckthorn", "#FFFBAC13" to "Sun", "#FFFBAED2" to "Lavender Pink", "#FFFBB2A3" to "Rose Bud", "#FFFBBEDA" to "Cupid", "#FFFBCCE7" to "Classic Rose", "#FFFBCEB1" to "Apricot Peach", "#FFFBE7B2" to "Banana Mania", "#FFFBE870" to "Marigold Yellow", "#FFFBE96C" to "Festival", "#FFFBEA8C" to "Sweet Corn", "#FFFBEC5D" to "Candy Corn", "#FFFBF9F9" to "Hint of Red", "#FFFBFFBA" to "Shalimar", "#FFFC0FC0" to "Shocking Pink", "#FFFC80A5" to "Tickle Me Pink", "#FFFC9C1D" to "Tree Poppy", "#FFFCC01E" to "Lightning Yellow", "#FFFCD667" to "Goldenrod", "#FFFCD917" to "Candlelight", "#FFFCDA98" to "Cherokee", "#FFFCF4D0" to "Double Pearl Lusta", "#FFFCF4DC" to "Pearl Lusta", "#FFFCF8F7" to "Vista White", "#FFFCFBF3" to "Bianca", "#FFFCFEDA" to "Moon Glow", "#FFFCFFE7" to "China Ivory", "#FFFCFFF9" to "Ceramic", "#FFFD0E35" to "Torch Red", "#FFFD5B78" to "Wild Watermelon", "#FFFD7B33" to "Crusta", "#FFFD7C07" to "Sorbus", "#FFFD9FA2" to "Sweet Pink", "#FFFDD5B1" to "Light Apricot", "#FFFDD7E4" to "Pig Pink", "#FFFDE1DC" to "Cinderella", "#FFFDE295" to "Golden Glow", "#FFFDE910" to "Lemon", "#FFFDF5E6" to "Old Lace", "#FFFDF6D3" to "Half Colonial White", "#FFFDF7AD" to "Drover", "#FFFDFEB8" to "Pale Prim", "#FFFDFFD5" to "Cumulus", "#FFFE28A2" to "Persian Rose", "#FFFE4C40" to "Sunset Orange", "#FFFE6F5E" to "Bittersweet", "#FFFE9D04" to "California", "#FFFEA904" to "Yellow Sea", "#FFFEBAAD" to "Melon", "#FFFED33C" to "Bright Sun", "#FFFED85D" to "Dandelion", "#FFFEDB8D" to "Salomie", "#FFFEE5AC" to "Cape Honey", "#FFFEEBF3" to "Remy", "#FFFEEFCE" to "Oasis", "#FFFEF0EC" to "Bridesmaid", "#FFFEF2C7" to "Beeswax", "#FFFEF3D8" to "Bleach White", "#FFFEF4CC" to "Pipi", "#FFFEF4DB" to "Half Spanish White", "#FFFEF4F8" to "Wisp Pink", "#FFFEF5F1" to "Provincial Pink", "#FFFEF7DE" to "Half Dutch White", "#FFFEF8E2" to "Solitaire", "#FFFEF8FF" to "White Pointer", "#FFFEF9E3" to "Off Yellow", "#FFFEFCED" to "Orange White", "#FFFF0000" to "Red", "#FFFF007F" to "Rose", "#FFFF00CC" to "Purple Pizzazz", "#FFFF00FF" to "Magenta / Fuchsia", "#FFFF2400" to "Scarlet", "#FFFF3399" to "Wild Strawberry", "#FFFF33CC" to "Razzle Dazzle Rose", "#FFFF355E" to "Radical Red", "#FFFF3F34" to "Red Orange", "#FFFF4040" to "Coral Red", "#FFFF4D00" to "Vermilion", "#FFFF4F00" to "International Orange", "#FFFF6037" to "Outrageous Orange", "#FFFF6600" to "Blaze Orange", "#FFFF66FF" to "Pink Flamingo", "#FFFF681F" to "Orange", "#FFFF69B4" to "Hot Pink", "#FFFF6B53" to "Persimmon", "#FFFF6FFF" to "Blush Pink", "#FFFF7034" to "Burning Orange", "#FFFF7518" to "Pumpkin", "#FFFF7D07" to "Flamenco", "#FFFF7F00" to "Flush Orange", "#FFFF7F50" to "Coral", "#FFFF8C69" to "Salmon", "#FFFF9000" to "Pizazz", "#FFFF910F" to "West Side", "#FFFF91A4" to "Pink Salmon", "#FFFF9933" to "Neon Carrot", "#FFFF9966" to "Atomic Tangerine", "#FFFF9980" to "Vivid Tangerine", "#FFFF9E2C" to "Sunshade", "#FFFFA000" to "Orange Peel", "#FFFFA194" to "Mona Lisa", "#FFFFA500" to "Web Orange", "#FFFFA6C9" to "Carnation Pink", "#FFFFAB81" to "Hit Pink", "#FFFFAE42" to "Yellow Orange", "#FFFFB0AC" to "Cornflower Lilac", "#FFFFB1B3" to "Sundown", "#FFFFB31F" to "My Sin", "#FFFFB555" to "Texas Rose", "#FFFFB7D5" to "Cotton Candy", "#FFFFB97B" to "Macaroni and Cheese", "#FFFFBA00" to "Selective Yellow", "#FFFFBD5F" to "Koromiko", "#FFFFBF00" to "Amber", "#FFFFC0A8" to "Wax Flower", "#FFFFC0CB" to "Pink", "#FFFFC3C0" to "Your Pink", "#FFFFC901" to "Supernova", "#FFFFCBA4" to "Flesh", "#FFFFCC33" to "Sunglow", "#FFFFCC5C" to "Golden Tainoi", "#FFFFCC99" to "Peach Orange", "#FFFFCD8C" to "Chardonnay", "#FFFFD1DC" to "Pastel Pink", "#FFFFD2B7" to "Romantic", "#FFFFD38C" to "Grandis", "#FFFFD700" to "Gold", "#FFFFD801" to "School bus Yellow", "#FFFFD8D9" to "Cosmos", "#FFFFDB58" to "Mustard", "#FFFFDCD6" to "Peach Schnapps", "#FFFFDDAF" to "Caramel", "#FFFFDDCD" to "Tuft Bush", "#FFFFDDCF" to "Watusi", "#FFFFDDF4" to "Pink Lace", "#FFFFDEAD" to "Navajo White", "#FFFFDEB3" to "Frangipani", "#FFFFE1DF" to "Pippin", "#FFFFE1F2" to "Pale Rose", "#FFFFE2C5" to "Negroni", "#FFFFE5A0" to "Cream Brulee", "#FFFFE5B4" to "Peach", "#FFFFE6C7" to "Tequila", "#FFFFE772" to "Kournikova", "#FFFFEAC8" to "Sandy Beach", "#FFFFEAD4" to "Karry", "#FFFFEC13" to "Broom", "#FFFFEDBC" to "Colonial White", "#FFFFEED8" to "Derby", "#FFFFEFA1" to "Vis Vis", "#FFFFEFC1" to "Egg White", "#FFFFEFD5" to "Papaya Whip", "#FFFFEFEC" to "Fair Pink", "#FFFFF0DB" to "Peach Cream", "#FFFFF0F5" to "Lavender blush", "#FFFFF14F" to "Gorse", "#FFFFF1B5" to "Buttermilk", "#FFFFF1D8" to "Pink Lady", "#FFFFF1EE" to "Forget Me Not", "#FFFFF1F9" to "Tutu", "#FFFFF39D" to "Picasso", "#FFFFF3F1" to "Chardon", "#FFFFF46E" to "Paris Daisy", "#FFFFF4CE" to "Barley White", "#FFFFF4DD" to "Egg Sour", "#FFFFF4E0" to "Sazerac", "#FFFFF4E8" to "Serenade", "#FFFFF4F3" to "Chablis", "#FFFFF5EE" to "Seashell Peach", "#FFFFF5F3" to "Sauvignon", "#FFFFF6D4" to "Milk Punch", "#FFFFF6DF" to "Varden", "#FFFFF6F5" to "Rose White", "#FFFFF8D1" to "Baja White", "#FFFFF9E2" to "Gin Fizz", "#FFFFF9E6" to "Early Dawn", "#FFFFFACD" to "Lemon Chiffon", "#FFFFFAF4" to "Bridal Heath", "#FFFFFBDC" to "Scotch Mist", "#FFFFFBF9" to "Soapstone", "#FFFFFC99" to "Witch Haze", "#FFFFFCEA" to "Buttery White", "#FFFFFCEE" to "Island Spice", "#FFFFFDD0" to "Cream", "#FFFFFDE6" to "Chilean Heath", "#FFFFFDE8" to "Travertine", "#FFFFFDF3" to "Orchid White", "#FFFFFDF4" to "Quarter Pearl Lusta", "#FFFFFEE1" to "Half and Half", "#FFFFFEEC" to "Apricot White", "#FFFFFEF0" to "Rice Cake", "#FFFFFEF6" to "Black White", "#FFFFFEFD" to "Romance", "#FFFFFF00" to "Yellow", "#FFFFFF66" to "Laser Lemon", "#FFFFFF99" to "Pale Canary", "#FFFFFFB4" to "Portafino", "#FFFFFFF0" to "Ivory", "#FFFFFFFF" to "White" ) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorpick/model/ColorNameParser.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorpick.model import androidx.compose.ui.graphics.Color import cn.netdiscovery.monica.ui.controlpanel.colorpick.utils.hexToRGB import cn.netdiscovery.monica.ui.controlpanel.colorpick.utils.toRGBArray import kotlin.math.sqrt /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorpick.ColorNameParser * @author: Tony Shen * @date: 2024/6/13 17:04 * @version: V1.0 <描述当前版本功能> */ const val Unspecified = "?????" internal data class RGBData(val x: Int, val y: Int, val z: Int, val label: String) class ColorNameParser internal constructor() { private val rbgData: List by lazy { colorNameMap.map { entry: Map.Entry -> val rgbArray = hexToRGB(entry.key) val label = entry.value RGBData( x = rgbArray[0], y = rgbArray[1], z = rgbArray[2], label = label ) } } /** * Parse name of [Color] */ fun parseColorName(color: Color): String { val rgbArray = color.toRGBArray() val red: Int = rgbArray[0] val green: Int = rgbArray[1] val blue: Int = rgbArray[2] var distance: Int=Int.MAX_VALUE var colorId = -1 rbgData.forEachIndexed { index, rgbData -> val currentDistance = sqrt( ( (rgbData.x - red) * (rgbData.x - red) + (rgbData.y - green) * (rgbData.y - green) + (rgbData.z - blue) * (rgbData.z - blue) ).toDouble() ).toInt() if (currentDistance < distance) { distance = currentDistance colorId = index } } return if (colorId >= 0) { rbgData[colorId].label } else Unspecified } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorpick/utils/ColorDetection.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorpick.utils import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.IntRect import org.jetbrains.skia.Bitmap /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorpick.ColorDetection * @author: Tony Shen * @date: 2024/6/13 22:03 * @version: V1.0 <描述当前版本功能> */ fun calculateColorInPixel( offsetX: Float, offsetY: Float, startImageX: Float = 0f, startImageY: Float = 0f, rect: IntRect, width: Float, height: Float, bitmap: Bitmap, ): Color { val bitmapWidth = bitmap.width val bitmapHeight = bitmap.height if (bitmapWidth == 0 || bitmapHeight == 0) return Color.Unspecified // End positions, this might be less than Image dimensions if bitmap doesn't fit Image val endImageX = width - startImageX val endImageY = height - startImageY val scaledX = scale( start1 = startImageX, end1 = endImageX, pos = offsetX, start2 = rect.left.toFloat(), end2 = rect.right.toFloat() ).toInt().coerceIn(0, bitmapWidth - 1) val scaledY = scale( start1 = startImageY, end1 = endImageY, pos = offsetY, start2 = rect.top.toFloat(), end2 = rect.bottom.toFloat() ).toInt().coerceIn(0, bitmapHeight - 1) val pixel: Int = bitmap.getColor(scaledX,scaledY) val red = pixel shr 16 and 0xFF val green = pixel shr 8 and 0xFF val blue = pixel and 0xFF return Color(red, green, blue) } /** * 线性插值 */ private fun lerp(start: Float, end: Float, amount: Float): Float { return (1 - amount) * start + amount * end } /** * Scale x1 from start1..end1 range to start2..end2 range */ private fun scale(start1: Float, end1: Float, pos: Float, start2: Float, end2: Float) = lerp(start2, end2, calculateFraction(start1, end1, pos)) private fun calculateFraction(start: Float, end: Float, pos: Float) = (if (end - start == 0f) 0f else (pos - start) / (end - start)).coerceIn(0f, 1f) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorpick/utils/ColorUtils.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorpick.utils import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import com.github.ajalt.colormath.model.RGB import com.github.ajalt.colormath.model.RGBInt /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorpick.ColorUtils * @author: Tony Shen * @date: 2024/6/13 17:10 * @version: V1.0 <描述当前版本功能> */ fun hexToRGB(colorString: String): IntArray { val completeColorString = if (colorString.first() == '#') colorString else "#$colorString" val rgb = RGB(completeColorString) return intArrayOf(rgb.redInt,rgb.greenInt, rgb.blueInt) } fun Color.toHex():String = RGBInt(this.toArgb().toUInt()).toSRGB().toHex() fun Color.toHSL():FloatArray = RGBInt(this.toArgb().toUInt()).toSRGB().toHSL().toArray() fun Color.toHSV():FloatArray = RGBInt(this.toArgb().toUInt()).toSRGB().toHSV().toArray() fun Color.toRGBArray(): IntArray { val rgb = RGBInt(this.toArgb().toUInt()).toSRGB() return intArrayOf(rgb.redInt,rgb.greenInt, rgb.blueInt) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorpick/utils/RoundngUtils.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorpick.utils import kotlin.math.roundToInt /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorpick.RoundngUtil * @author: Tony Shen * @date: 2024/6/13 20:37 * @version: V1.0 <描述当前版本功能> */ /** * Converts alpha, red, green or blue values from range of [0f-1f] to [0-255]. */ fun Float.fractionToRGBRange() = (this * 255.0f).toInt() /** * Converts alpha, red, green or blue values from range of [0f-1f] to [0-255] and returns * it as [String]. */ fun Float.fractionToRGBString() = this.fractionToRGBRange().toString() /** * Rounds this [Float] to another with 2 significant numbers * 0.1234 is rounded to 0.12 * 0.127 is rounded to 0.13 */ fun Float.roundToTwoDigits() = (this * 100.0f).roundToInt() / 100.0f /** * Rounds this [Float] to closest int. */ fun Float.round() = this.roundToInt() /** * Converts **HSV** or **HSL** colors that are in range of [0f-1f] to [0-100] range in [Integer] * with [Float.roundToInt] */ fun Float.fractionToPercent() = (this * 100.0f).roundToInt() /** * Converts **HSV** or **HSL** colors that are in range of [0f-1f] to [0-100] range in [Integer] * with [Float.toInt] */ fun Float.fractionToIntPercent() = (this * 100.0f).toInt() ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorpick/widget/ColorDisplay.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorpick.widget import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.ui.controlpanel.colorpick.model.ColorData import cn.netdiscovery.monica.ui.controlpanel.colorpick.utils.toHSL /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorpick.ColorDisplay * @author: Tony Shen * @date: 2024/6/14 11:58 * @version: V1.0 <描述当前版本功能> */ @Composable fun ColorDisplay( modifier: Modifier = Modifier, colorData: ColorData ) { val color = colorData.color val colorName = colorData.name val lightness = color.toHSL()[2] val textColor = if (lightness < .6f) Color.White else Color.Black val hexText = colorData.hexText Column( modifier = modifier .shadow(2.dp, RoundedCornerShape(10)) .width(170.dp) .background(color = color) .padding(start = 16.dp, end = 2.dp, top = 2.dp, bottom = 2.dp), ) { Row { Column { Text(text = hexText, fontSize = 20.sp, color = textColor) } } Column { Text(text = colorData.rgb, fontSize = 12.sp, color = textColor) Text(text = colorData.hslString, fontSize = 12.sp, color = textColor) Text(text = colorData.hsvString, fontSize = 12.sp, color = textColor) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorpick/widget/ColorSelectionDrawing.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorpick.widget import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.ui.controlpanel.colorpick.defaultThumbnailSize import kotlin.math.roundToInt /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorpick.ColorSelectionDrawing * @author: Tony Shen * @date: 2024/6/14 10:57 * @version: V1.0 <描述当前版本功能> */ @Composable internal fun ColorSelectionDrawing( modifier: Modifier, thumbnailSize: Dp = defaultThumbnailSize, offset: Offset, thumbnailCenter: Offset, color: Color ) { Canvas(modifier = modifier.fillMaxSize()) { val canvasWidth = size.width val canvasHeight = size.height if (color != Color.Unspecified && offset.isSpecified && thumbnailCenter.isSpecified) { // Get thumb size as parameter but limit max size to minimum of canvasWidth and Height val imageThumbSize: Int = thumbnailSize.toPx() .coerceAtMost(canvasWidth.coerceAtLeast(canvasHeight)).roundToInt() val radius: Float = 8.dp.toPx() // Draw touch position circle drawCircle( Color.Black, radius = radius * 1.4f, center = offset, style = Stroke(radius * 0.4f) ) drawCircle( Color.White, radius = radius * 1.0f, center = offset, style = Stroke(radius * 0.4f) ) // Draw thumbnail center circle drawCircle( color = Color.Black, radius = radius, center = thumbnailCenter, style = Stroke(radius * .5f) ) drawCircle( color = Color.White, radius = radius, center = thumbnailCenter, style = Stroke(radius * .2f) ) drawCircle( color = color, radius = imageThumbSize / 20f, center = thumbnailCenter, ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/colorpick/widget/ImageColorDetector.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.colorpick.widget import androidx.compose.foundation.layout.size import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.isFinite import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asSkiaBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import cn.netdiscovery.monica.ui.controlpanel.colorpick.OnColorChange import cn.netdiscovery.monica.ui.controlpanel.colorpick.defaultThumbnailSize import cn.netdiscovery.monica.ui.controlpanel.colorpick.model.ColorData import cn.netdiscovery.monica.ui.controlpanel.colorpick.model.ColorNameParser import cn.netdiscovery.monica.ui.controlpanel.colorpick.utils.calculateColorInPixel import cn.netdiscovery.monica.ui.widget.image.ImageWithThumbnail import cn.netdiscovery.monica.ui.widget.image.rememberThumbnailState import com.safframework.kotlin.coroutines.IO import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.colorpick.ImageColorDetector * @author: Tony Shen * @date: 2024/6/13 20:13 * @version: V1.0 <描述当前版本功能> */ @Composable fun ImageColorDetector( modifier: Modifier = Modifier, imageBitmap: ImageBitmap, contentScale: ContentScale = ContentScale.FillBounds, alignment: Alignment = Alignment.Center, colorNameParser: ColorNameParser, thumbnailSize: Dp = defaultThumbnailSize, thumbnailZoom: Int = 200, onColorChange: OnColorChange ) { var offset by remember(imageBitmap, contentScale) { mutableStateOf(Offset.Unspecified) } var center by remember(imageBitmap, contentScale) { mutableStateOf(Offset.Unspecified) } var color by remember { mutableStateOf(Color.Unspecified) } LaunchedEffect(key1 = colorNameParser) { snapshotFlow { color } .distinctUntilChanged() .mapLatest { color: Color -> colorNameParser.parseColorName(color) } .flowOn(IO) .collect { name: String -> onColorChange(ColorData(color, name)) } } ImageWithThumbnail( imageBitmap = imageBitmap, modifier = modifier, contentDescription = "Image Color Detector", contentScale = contentScale, alignment = alignment, thumbnailState = rememberThumbnailState( size = DpSize(thumbnailSize, thumbnailSize), thumbnailZoom = thumbnailZoom, ), onThumbnailCenterChange = { center = it }, onDown = { offset = it }, onMove = { offset = it } ) { val density = LocalDensity.current.density if (offset.isSpecified && offset.isFinite) { color = calculateColorInPixel( offsetX = offset.x, offsetY = offset.y, startImageX = 0f, startImageY = 0f, rect = rect, width = imageWidth.value * density, height = imageHeight.value * density, bitmap = imageBitmap.asSkiaBitmap() ) } ColorSelectionDrawing( modifier = Modifier.size(imageWidth, imageHeight), offset = offset, thumbnailCenter = center, color = color ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/compression/CompressionActions.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.compression import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.ImageCompressionUtils import java.io.File import javax.swing.JFileChooser import javax.swing.JFrame @Composable fun CompressionActionButtons( viewModel: CompressionViewModel, state: ApplicationState, onShowToast: (String) -> Unit, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Button( onClick = { viewModel.applyCompressedImage(state) onShowToast(i18nState.getString("applied_to_editor")) }, modifier = Modifier .weight(1f) .height(40.dp), colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.primary ) ) { Text( i18nState.getString("apply_to_editor"), color = Color.White, fontWeight = FontWeight.Bold ) } Button( onClick = { // 撤销:优先撤销“应用到编辑器”(如果有),同时重置参数并清理压缩结果,回到原图预览 val ok = viewModel.undoApplied(state) viewModel.resetAll() onShowToast( if (ok) i18nState.getString("undo_and_reset_success") else i18nState.getString("reset_done") ) }, modifier = Modifier .width(92.dp) .height(40.dp), colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.7f) ) ) { Text( i18nState.getString("undo"), color = Color.White ) } Button( onClick = { val fileChooser = JFileChooser() fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY fileChooser.selectedFile = File("compressed.${viewModel.selectedAlgorithm.format}") val result = fileChooser.showSaveDialog(JFrame()) if (result == JFileChooser.APPROVE_OPTION) { val outputFile = fileChooser.selectedFile val saveResult = viewModel.saveLastCompressedToFile(outputFile) if (saveResult != null) { onShowToast(i18nState.getString("save_success").format(saveResult.outputFile.absolutePath)) } else { onShowToast(i18nState.getString("save_failed")) } } }, modifier = Modifier .weight(1f) .height(40.dp), colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.7f) ) ) { Text( i18nState.getString("save"), color = Color.White ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/compression/CompressionAlgorithmDropdown.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.compression import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.utils.CompressionAlgorithm @Composable fun CompressionAlgorithmDropdown( viewModel: CompressionViewModel, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState ) { var expanded by remember { mutableStateOf(false) } Column { Text( text = i18nState.getString("compression_algorithm"), style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp) ) Box { OutlinedButton( onClick = { expanded = true }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.outlinedButtonColors( backgroundColor = MaterialTheme.colors.surface ) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = viewModel.selectedAlgorithm.displayName, color = MaterialTheme.colors.onSurface ) Text( text = "▼", color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp ) } } DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, modifier = Modifier.fillMaxWidth() ) { CompressionAlgorithm.entries.forEach { algorithm -> DropdownMenuItem( onClick = { viewModel.selectedAlgorithm = algorithm expanded = false } ) { Text(algorithm.displayName) } } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/compression/CompressionInputSection.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.compression import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import java.io.File import javax.swing.JFileChooser import javax.swing.JFrame @Composable fun CompressionInputSection( compressionMode: CompressionMode, onModeChange: (CompressionMode) -> Unit, selectedOutputDir: File?, onOutputDirSelected: (File) -> Unit, viewModel: CompressionViewModel, state: ApplicationState, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState, onShowToast: (String) -> Unit ) { Column( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = i18nState.getString("input_selection"), style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Button( onClick = { onModeChange(CompressionMode.SINGLE) }, modifier = Modifier .weight(1f) .height(40.dp), colors = ButtonDefaults.buttonColors( backgroundColor = if (compressionMode == CompressionMode.SINGLE) MaterialTheme.colors.primary else MaterialTheme.colors.surface ) ) { Icon( painter = painterResource("images/controlpanel/compress.png"), contentDescription = null, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text(i18nState.getString("single_image")) } Button( onClick = { onModeChange(CompressionMode.BATCH) }, modifier = Modifier .weight(1f) .height(40.dp), colors = ButtonDefaults.buttonColors( backgroundColor = if (compressionMode == CompressionMode.BATCH) MaterialTheme.colors.primary else MaterialTheme.colors.surface ) ) { Icon( painter = painterResource("images/controlpanel/compress.png"), contentDescription = null, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text(i18nState.getString("batch_folder")) } } if (compressionMode == CompressionMode.SINGLE) { // 单张图模式:只显示"开始压缩"按钮 Button( onClick = { if (viewModel.selectedImage == null) { onShowToast(i18nState.getString("please_select_image_in_preview")) return@Button } // 开始压缩(不选择保存位置,压缩后显示在右侧预览区) viewModel.compressSingleImageToPreview(state.scope) { i18nState.getString(it) } }, enabled = !viewModel.isCompressing && viewModel.selectedImage != null, modifier = Modifier .fillMaxWidth() .height(40.dp), colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.primary ) ) { Text( i18nState.getString("start_compression"), color = Color.White, fontWeight = FontWeight.Bold ) } // Reset:重置参数 + 清掉压缩结果(恢复到原图预览) OutlinedButton( onClick = { viewModel.resetAll() }, enabled = !viewModel.isCompressing && (!viewModel.isAtDefaultParams() || viewModel.showResult), modifier = Modifier .fillMaxWidth() .height(40.dp) ) { Text(i18nState.getString("reset")) } } else { Button( onClick = { val fileChooser = JFileChooser() fileChooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY val result = fileChooser.showOpenDialog(JFrame()) if (result == JFileChooser.APPROVE_OPTION) { onOutputDirSelected(fileChooser.selectedFile) } }, modifier = Modifier .fillMaxWidth() .height(40.dp), colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.7f) ) ) { Text( if (selectedOutputDir == null) i18nState.getString("select_input_folder") else "${i18nState.getString("selected")}: ${selectedOutputDir!!.name}", color = Color.White ) } if (selectedOutputDir != null) { Button( onClick = { val fileChooser = JFileChooser() fileChooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY fileChooser.dialogTitle = i18nState.getString("select_output_folder") val result = fileChooser.showOpenDialog(JFrame()) if (result == JFileChooser.APPROVE_OPTION) { val outputDir = fileChooser.selectedFile // 检查输出文件夹中是否已有文件 val existingFiles = outputDir.listFiles()?.filter { it.isFile }?.size ?: 0 if (existingFiles > 0) { val confirmResult = javax.swing.JOptionPane.showConfirmDialog( null, i18nState.getString("output_folder_has_files").format(existingFiles), i18nState.getString("batch_compression_warning"), javax.swing.JOptionPane.YES_NO_OPTION, javax.swing.JOptionPane.WARNING_MESSAGE ) if (confirmResult != javax.swing.JOptionPane.YES_OPTION) { return@Button } } viewModel.compressBatch(selectedOutputDir!!, outputDir, state.scope) { i18nState.getString(it) } } }, enabled = !viewModel.isCompressing, modifier = Modifier .fillMaxWidth() .height(40.dp), colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.primary ) ) { Text( i18nState.getString("start_batch_compression"), color = Color.White, fontWeight = FontWeight.Bold ) } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/compression/CompressionPreview.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.compression import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlin.math.abs import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.* import cn.netdiscovery.monica.utils.chooseImage import java.io.File import javax.imageio.ImageIO @Composable fun CompressionRightPanel( modifier: Modifier = Modifier, state: ApplicationState, viewModel: CompressionViewModel, compressionMode: CompressionMode, onShowToast: (String) -> Unit, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState ) { Card( modifier = modifier, shape = RoundedCornerShape(12.dp), elevation = 4.dp, backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.fillMaxSize() ) { // 图片预览区域 Box( modifier = Modifier .weight(1f) .fillMaxWidth() .background(MaterialTheme.colors.background) .padding(16.dp), contentAlignment = Alignment.Center ) { CompressionPreviewArea( state = state, viewModel = viewModel, compressionMode = compressionMode, onShowToast = onShowToast, i18nState = i18nState ) } // WebP 降级警告 if (viewModel.webpFallbackWarning != null) { Card( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), backgroundColor = MaterialTheme.colors.error.copy(alpha = 0.1f), shape = RoundedCornerShape(8.dp) ) { Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( painter = painterResource("images/controlpanel/compress.png"), contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colors.error ) Text( text = viewModel.webpFallbackWarning!!, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.error ) } } } // 压缩后文件变大提示 if (viewModel.sizeChangeWarning != null) { Card( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), backgroundColor = MaterialTheme.colors.error.copy(alpha = 0.08f), shape = RoundedCornerShape(8.dp) ) { Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( painter = painterResource("images/controlpanel/compress.png"), contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colors.error ) Text( text = viewModel.sizeChangeWarning!!, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.error ) } } } // 文件大小信息和操作按钮(仅在单张图模式下显示操作按钮) if (viewModel.showResult) { CompressionResultInfo( viewModel = viewModel, state = state, compressionMode = compressionMode, onShowToast = onShowToast, i18nState = i18nState ) } } } } @Composable private fun CompressionPreviewArea( state: ApplicationState, viewModel: CompressionViewModel, compressionMode: CompressionMode, onShowToast: (String) -> Unit, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState ) { // 批量模式:右侧仅作为信息展示区,避免“无意义预览” if (compressionMode == CompressionMode.BATCH) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( painter = painterResource("images/controlpanel/compress.png"), contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colors.onSurface.copy(alpha = 0.35f) ) Text( text = i18nState.getString("batch_mode_no_preview"), color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), style = MaterialTheme.typography.body2 ) } return } val original = viewModel.selectedImage val compressed = viewModel.compressedImage if (original == null) { if (compressionMode == CompressionMode.SINGLE) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { Icon( painter = painterResource("images/controlpanel/compress.png"), contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colors.onSurface.copy(alpha = 0.4f) ) Text( text = i18nState.getString("please_select_image_to_compress"), color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), style = MaterialTheme.typography.body1 ) Button( onClick = { chooseImage(state) { selectedFile -> try { val fileSize = selectedFile.length() val image = ImageIO.read(selectedFile) if (image != null) { viewModel.selectedImage = image viewModel.selectedImageFile = selectedFile viewModel.selectedImageFileSize = fileSize } else { onShowToast(i18nState.getString("cannot_read_image_file")) } } catch (e: Exception) { onShowToast(i18nState.getString("load_image_failed").format(e.message ?: "")) } } }, enabled = !viewModel.isCompressing, colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.primary) ) { Text(i18nState.getString("select_image"), color = Color.White, fontWeight = FontWeight.Bold) } } } else { Text( text = i18nState.getString("please_select_or_load_image"), color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) ) } return } // 单图模式:右侧优先展示"压缩后的预览";Reset/清理结果后会回到原图展示 val displayImage = if (viewModel.showResult && compressed != null) compressed else original Box(modifier = Modifier.fillMaxSize()) { Image( painter = displayImage.toPainter(), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit ) // 清除图像按钮(悬浮在右上角) IconButton( onClick = { viewModel.clearSelectedImage() onShowToast(i18nState.getString("image_cleared")) }, modifier = Modifier .align(Alignment.TopEnd) .padding(8.dp) .background( MaterialTheme.colors.surface.copy(alpha = 0.9f), RoundedCornerShape(8.dp) ), enabled = !viewModel.isCompressing ) { Icon( imageVector = Icons.Default.Close, contentDescription = i18nState.getString("clear_image"), tint = MaterialTheme.colors.error, modifier = Modifier.size(24.dp) ) } } } @Composable private fun CompressionResultInfo( viewModel: CompressionViewModel, state: ApplicationState, compressionMode: CompressionMode, onShowToast: (String) -> Unit, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = "${i18nState.getString("original")}: ${ImageCompressionUtils.formatFileSize(viewModel.originalSize)}", style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onSurface ) Text( text = run { val original = viewModel.originalSize val compressed = viewModel.compressedSize val ratioLabel = if (original > 0 && compressed > original) { i18nState.getString("size_increase") } else { i18nState.getString("compression_ratio") } val percent = if (original > 0) abs((100 * (1 - compressed.toDouble() / original)).toInt()) else 0 "${i18nState.getString("compressed")}: ${ImageCompressionUtils.formatFileSize(compressed)} $ratioLabel: $percent%" }, style = MaterialTheme.typography.body2, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary ) } // 操作按钮(仅在单张图模式下显示) if (compressionMode == CompressionMode.SINGLE) { CompressionActionButtons( viewModel = viewModel, state = state, onShowToast = onShowToast, i18nState = i18nState ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/compression/CompressionProgress.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.compression import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable fun CompressionProgressSection( viewModel: CompressionViewModel, onCancel: () -> Unit, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { // 压缩消息和取消按钮 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { // 压缩消息 if (viewModel.compressionMessage.isNotEmpty()) { Text( text = viewModel.compressionMessage, style = MaterialTheme.typography.body2, modifier = Modifier.weight(1f), color = when { viewModel.compressionMessage.contains(i18nState.getString("compression_success").replace("!", "")) || viewModel.compressionMessage.contains(i18nState.getString("batch_compression_completed").split("(")[0]) -> MaterialTheme.colors.primary viewModel.compressionMessage.contains(i18nState.getString("compression_failed")) || viewModel.compressionMessage.contains(i18nState.getString("compression_error").split(":")[0]) || viewModel.compressionMessage.contains(i18nState.getString("compression_cancelled")) -> MaterialTheme.colors.error else -> MaterialTheme.colors.onSurface }, fontWeight = FontWeight.Medium ) } else { Spacer(modifier = Modifier.weight(1f)) } // 取消按钮(仅在压缩中显示) if (viewModel.isCompressing) { Button( onClick = onCancel, modifier = Modifier.height(32.dp), colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.error ) ) { Text( i18nState.getString("cancel"), color = Color.White, fontSize = 12.sp ) } } } // 进度条 if (viewModel.isCompressing) { LinearProgressIndicator( progress = viewModel.compressionProgress, modifier = Modifier .fillMaxWidth() .height(8.dp), color = MaterialTheme.colors.primary ) Text( text = "${(viewModel.compressionProgress * 100).toInt()}%", style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/compression/CompressionSliders.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.compression import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.ui.i18n.I18nState @Composable fun QualitySlider( value: Float, onValueChange: (Float) -> Unit, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState ) { Column { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = i18nState.getString("quality_setting"), style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold ) Text( text = "${(value * 100).toInt()}%", style = MaterialTheme.typography.body2, color = MaterialTheme.colors.primary, fontWeight = FontWeight.Bold ) } Slider( value = value, onValueChange = onValueChange, valueRange = 0f..1f, modifier = Modifier.fillMaxWidth(), colors = SliderDefaults.colors( thumbColor = MaterialTheme.colors.primary, activeTrackColor = MaterialTheme.colors.primary ) ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text("0", style = MaterialTheme.typography.caption) Text("${(value * 100).toInt()}%", style = MaterialTheme.typography.caption) } } } @Composable fun CompressionLevelSlider( value: Int, onValueChange: (Int) -> Unit, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState ) { // 使用浮点值保持拖动时的平滑性 var sliderValue by remember(value) { mutableStateOf(value.toFloat()) } // 当外部值改变时同步更新滑块值 LaunchedEffect(value) { sliderValue = value.toFloat() } Column { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = i18nState.getString("compression_level"), style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold ) Text( text = "${sliderValue.toInt()}/9", style = MaterialTheme.typography.body2, color = MaterialTheme.colors.primary, fontWeight = FontWeight.Bold ) } Slider( value = sliderValue, onValueChange = { newValue -> // 拖动过程中保持浮点值,确保平滑 sliderValue = newValue.coerceIn(0f, 9f) }, onValueChangeFinished = { // 拖动结束时才转换为整数并通知外部 onValueChange(sliderValue.toInt().coerceIn(0, 9)) }, valueRange = 0f..9f, modifier = Modifier.fillMaxWidth(), colors = SliderDefaults.colors( thumbColor = MaterialTheme.colors.primary, activeTrackColor = MaterialTheme.colors.primary ) ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/compression/CompressionView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.compression import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.utils.CompressionAlgorithm import cn.netdiscovery.monica.utils.ImageCompressionUtils import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File /** * 图像压缩 UI 视图 * * @author: Tony Shen * @date: 2025/12/07 * @version: V2.0 */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) @Composable fun compressionView(state: ApplicationState) { val viewModel: CompressionViewModel = remember { CompressionViewModel() } val i18nState = rememberI18nState() var compressionMode by remember { mutableStateOf(CompressionMode.SINGLE) } var selectedOutputDir by remember { mutableStateOf(null) } // Toast 状态(提升到顶层,使 toast 在整个页面居中) var showToast by remember { mutableStateOf(false) } var toastMessage by remember { mutableStateOf("") } // 当切换模式时,重置结果 LaunchedEffect(compressionMode) { viewModel.resetResult() if (compressionMode == CompressionMode.BATCH) { viewModel.selectedImage = null viewModel.selectedImageFileSize = 0L } } // 显示 Toast(在整个页面居中) if (showToast) { cn.netdiscovery.monica.ui.widget.centerToast( modifier = Modifier.fillMaxSize(), message = toastMessage ) { showToast = false } } // 左右分栏布局 Row( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colors.background) .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { // 左侧控制面板 LeftControlPanel( modifier = Modifier .width(400.dp) .fillMaxHeight(), viewModel = viewModel, compressionMode = compressionMode, onModeChange = { compressionMode = it }, selectedOutputDir = selectedOutputDir, onOutputDirSelected = { selectedOutputDir = it }, state = state, i18nState = i18nState, onShowToast = { message -> toastMessage = message showToast = true } ) // 右侧图片预览对比区域 CompressionRightPanel( modifier = Modifier .weight(1f) .fillMaxHeight(), state = state, viewModel = viewModel, compressionMode = compressionMode, onShowToast = { message -> toastMessage = message showToast = true }, i18nState = i18nState ) } } @Composable private fun LeftControlPanel( modifier: Modifier = Modifier, viewModel: CompressionViewModel, compressionMode: CompressionMode, onModeChange: (CompressionMode) -> Unit, selectedOutputDir: File?, onOutputDirSelected: (File) -> Unit, state: ApplicationState, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState, onShowToast: (String) -> Unit ) { Card( modifier = modifier, shape = RoundedCornerShape(12.dp), elevation = 4.dp, backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier .fillMaxSize() .padding(20.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(20.dp) ) { // 标题 Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( painter = painterResource("images/controlpanel/compress.png"), contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colors.primary ) Text( text = i18nState.getString("image_compression"), style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary ) } Divider() // 压缩算法选择(下拉菜单样式) CompressionAlgorithmDropdown( viewModel = viewModel, i18nState = i18nState ) // WebP 不支持提示 if ((viewModel.selectedAlgorithm == CompressionAlgorithm.WEBP_LOSSY || viewModel.selectedAlgorithm == CompressionAlgorithm.WEBP_LOSSLESS) && !ImageCompressionUtils.isWebPSupported()) { Card( modifier = Modifier.fillMaxWidth(), backgroundColor = MaterialTheme.colors.error.copy(alpha = 0.1f), shape = RoundedCornerShape(8.dp) ) { Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( painter = painterResource("images/controlpanel/compress.png"), contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colors.error ) Text( text = i18nState.getString("webp_not_supported_auto_convert").format(ImageCompressionUtils.getWebPFallbackFormat(viewModel.selectedAlgorithm == CompressionAlgorithm.WEBP_LOSSY)), style = MaterialTheme.typography.caption, color = MaterialTheme.colors.error ) } } } Divider() // 质量设置 when (viewModel.selectedAlgorithm) { CompressionAlgorithm.JPEG_QUALITY, CompressionAlgorithm.WEBP_LOSSY -> { QualitySlider( value = viewModel.quality, onValueChange = { viewModel.quality = it }, i18nState = i18nState ) } CompressionAlgorithm.PNG_OPTIMIZATION, CompressionAlgorithm.WEBP_LOSSLESS -> { CompressionLevelSlider( value = viewModel.compressionLevel, onValueChange = { viewModel.compressionLevel = it }, i18nState = i18nState ) } } Divider() // 输入选择 CompressionInputSection( compressionMode = compressionMode, onModeChange = onModeChange, selectedOutputDir = selectedOutputDir, onOutputDirSelected = onOutputDirSelected, viewModel = viewModel, state = state, i18nState = i18nState, onShowToast = onShowToast ) // 压缩进度和消息显示 if (viewModel.isCompressing || viewModel.compressionMessage.isNotEmpty()) { Divider() CompressionProgressSection( viewModel = viewModel, onCancel = { viewModel.cancelCompression { i18nState.getString(it) } }, i18nState = i18nState ) } } } } /** * 压缩模式枚举 */ enum class CompressionMode(val displayName: String) { SINGLE("单张图片"), BATCH("批量图片") } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/compression/CompressionViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.compression import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File import javax.imageio.ImageIO /** * 图像压缩 ViewModel * * @author: Tony Shen * @date: 2025/12/07 * @version: V1.0 */ private val logger: Logger = LoggerFactory.getLogger(CompressionViewModel::class.java) class CompressionViewModel { companion object { private val DEFAULT_ALGORITHM: CompressionAlgorithm = CompressionAlgorithm.JPEG_QUALITY private const val DEFAULT_QUALITY: Float = 0.8f private const val DEFAULT_COMPRESSION_LEVEL: Int = 6 } private suspend fun ui(block: () -> Unit) { withContext(Dispatchers.Main) { block() } } // 压缩算法选择 var selectedAlgorithm by mutableStateOf(CompressionAlgorithm.JPEG_QUALITY) // JPEG 和 WebP Lossy 的质量参数(0.0 - 1.0) var quality by mutableStateOf(0.8f) // PNG 和 WebP Lossless 的压缩级别(0 - 9) var compressionLevel by mutableStateOf(6) // 压缩进度显示 var isCompressing by mutableStateOf(false) var compressionProgress by mutableStateOf(0f) var compressionMessage by mutableStateOf("") // 压缩结果统计 var originalSize by mutableStateOf(0L) var compressedSize by mutableStateOf(0L) var compressionRatio by mutableStateOf(0) var showResult by mutableStateOf(false) // 单张图模式下选择的图片 var selectedImage by mutableStateOf(null) // 单张图模式下选择的原始文件 var selectedImageFile by mutableStateOf(null) // 单张图模式下选择的原始文件大小 var selectedImageFileSize by mutableStateOf(0L) // 压缩后的图片 var compressedImage by mutableStateOf(null) // 单张图模式:复用预览阶段的压缩结果,避免“预览压一次,保存再压一次”导致 JPG 不稳定 private var lastCompressedData: ByteArray? = null private var lastCompressedUsedFallback: Boolean = false private var lastCompressedParams: CompressionParams? = null // WebP 降级提示 var webpFallbackWarning by mutableStateOf(null) // 压缩后文件变大提示(例如 JPG 重新编码质量更高时) var sizeChangeWarning by mutableStateOf(null) // Undo:保存“应用到编辑器”前的快照(允许为 null,兼容编辑器未加载图片) private var hasAppliedSnapshot: Boolean = false private var lastAppliedPrevCurrent: java.awt.image.BufferedImage? = null private var lastAppliedPrevRaw: java.awt.image.BufferedImage? = null private var lastAppliedPrevFile: File? = null // 批量压缩时的文件数 var totalFiles by mutableStateOf(0) var processedFiles by mutableStateOf(0) // 压缩任务引用(用于取消) private var compressionJob: Job? = null /** * 获取当前的压缩参数 */ fun getCurrentParams(): CompressionParams { return CompressionParams( algorithm = selectedAlgorithm, quality = quality, compressionLevel = compressionLevel ) } /** * 压缩单张图片到预览(不保存文件) */ fun compressSingleImageToPreview( scope: CoroutineScope, getString: (String) -> String ) { // 取消之前的任务 compressionJob?.cancel() compressionJob = scope.launch(Dispatchers.Default) { try { val image = selectedImage ?: run { ui { compressionMessage = getString("error_please_select_image") } return@launch } // 更新状态(UI 线程) ui { isCompressing = true compressionMessage = getString("compressing_image") compressionProgress = 0.3f } val params = getCurrentParams() // 使用原始文件的实际大小 ui { originalSize = selectedImageFileSize } ui { compressionProgress = 0.6f } // 检查 WebP 支持 if ((params.algorithm == CompressionAlgorithm.WEBP_LOSSY || params.algorithm == CompressionAlgorithm.WEBP_LOSSLESS) && !ImageCompressionUtils.isWebPSupported()) { val fallbackFormat = ImageCompressionUtils.getWebPFallbackFormat( params.algorithm == CompressionAlgorithm.WEBP_LOSSY ) ui { webpFallbackWarning = getString("webp_not_supported").format(fallbackFormat) } } else { // 检查格式转换警告(JPG 转 PNG 等) val formatWarningKey = ImageCompressionUtils.checkFormatConversionWarning( selectedImageFile, params.algorithm ) ui { webpFallbackWarning = formatWarningKey?.let { getString(it) } } } // 压缩到内存 val compressedBytes = ImageCompressionUtils.compressImage(image, params) if (compressedBytes != null) { val (compressedData, usedFallback) = compressedBytes lastCompressedData = compressedData lastCompressedUsedFallback = usedFallback lastCompressedParams = params val localCompressedSize = compressedData.size.toLong() // 如果使用了降级处理,更新警告信息 if (usedFallback && webpFallbackWarning == null) { val fallbackFormat = ImageCompressionUtils.getWebPFallbackFormat( params.algorithm == CompressionAlgorithm.WEBP_LOSSY ) ui { webpFallbackWarning = getString("webp_encode_failed").format(fallbackFormat) } } // 从压缩后的字节数组加载图片 val compressedImageData = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(compressedData)) ui { compressedSize = localCompressedSize compressionRatio = ImageCompressionUtils.calculateCompressionRatio(originalSize, compressedSize) sizeChangeWarning = if (originalSize > 0 && compressedSize >= originalSize) { getString("compressed_file_larger_warning") } else { null } if (compressedImageData != null) { compressedImage = compressedImageData } compressionMessage = getString("compression_success") compressionProgress = 1f showResult = true } } else { ui { compressionMessage = getString("compression_failed") compressionProgress = 0f } } ui { isCompressing = false } } catch (e: Exception) { logger.error("Single image compression error", e) ui { compressionMessage = getString("compression_error").format(e.message ?: "") compressionProgress = 0f isCompressing = false sizeChangeWarning = null } } } compressionJob?.invokeOnCompletion { compressionJob = null } } /** * 批量压缩文件夹中的所有图片 * 使用流式处理优化内存使用 */ fun compressBatch( sourceDir: File, outputDir: File, scope: CoroutineScope, getString: (String) -> String ) { // 取消之前的任务 compressionJob?.cancel() compressionJob = scope.launch(Dispatchers.Default) { try { ui { isCompressing = true compressionMessage = getString("preparing_batch_compression") compressionProgress = 0f } val params = getCurrentParams() val imageExtensions = setOf("jpg", "jpeg", "png", "bmp", "gif", "tiff") // 先统计文件数量(用于进度显示) var fileCount = 0 sourceDir.walk().forEach { file -> if (file.isFile && file.extension.lowercase() in imageExtensions) { fileCount++ } } ui { totalFiles = fileCount processedFiles = 0 } if (totalFiles == 0) { ui { compressionMessage = getString("no_images_in_folder") isCompressing = false } return@launch } if (!outputDir.exists()) { outputDir.mkdirs() } var totalOriginalSize = 0L var totalCompressedSize = 0L // 使用流式处理,逐个处理文件,避免一次性加载所有文件到内存 sourceDir.walk().forEach { file -> // 检查是否已取消 if (!isActive) { ui { compressionMessage = getString("compression_cancelled") } return@forEach } if (!file.isFile || file.extension.lowercase() !in imageExtensions) { return@forEach } try { ui { processedFiles++ compressionMessage = getString("compressing_file").format(file.name, processedFiles, totalFiles) compressionProgress = processedFiles.toFloat() / totalFiles } // 读取图片(处理完立即释放) val image = ImageIO.read(file) ?: run { return@forEach } val baseName = file.nameWithoutExtension val outputFileName = "$baseName.${params.algorithm.format}" val outputFile = File(outputDir, outputFileName) val fileOriginalSize = file.length() val result = ImageCompressionUtils.compressAndSaveImage(image, outputFile, params) // 立即释放图片内存 image.flush() if (result != null) { val savedSize = result.sizeBytes totalOriginalSize += fileOriginalSize totalCompressedSize += savedSize } } catch (e: Exception) { logger.error("File processing error: ${file.absolutePath}", e) } } ui { originalSize = totalOriginalSize compressedSize = totalCompressedSize compressionRatio = ImageCompressionUtils.calculateCompressionRatio(totalOriginalSize, totalCompressedSize) sizeChangeWarning = if (totalOriginalSize > 0 && totalCompressedSize >= totalOriginalSize) { getString("compressed_file_larger_warning") } else { null } compressionMessage = getString("batch_compression_completed").format(processedFiles, totalFiles) showResult = true } } catch (e: Exception) { logger.error("Batch compression error", e) ui { compressionMessage = getString("batch_compression_error").format(e.message ?: "") sizeChangeWarning = null } } finally { ui { isCompressing = false } compressionJob = null } } } /** * 取消压缩任务 */ fun cancelCompression(getString: (String) -> String) { compressionJob?.cancel() compressionJob = null isCompressing = false compressionMessage = getString("compression_cancelled") } /** * 重置压缩结果(切换模式时调用) */ fun resetResult() { // 取消正在进行的任务(静默) compressionJob?.cancel() compressionJob = null isCompressing = false showResult = false // 注意:不清空 originalSize,因为单张图模式下它应该保留原始文件大小 // 批量模式下 originalSize 会在 compressBatch 中重新计算 compressedSize = 0L compressionRatio = 0 processedFiles = 0 totalFiles = 0 compressionProgress = 0f compressionMessage = "" compressedImage = null webpFallbackWarning = null sizeChangeWarning = null lastCompressedData = null lastCompressedUsedFallback = false lastCompressedParams = null } /** * Reset 语义:重置参数 + 清掉压缩结果 */ fun resetAll() { selectedAlgorithm = DEFAULT_ALGORITHM quality = DEFAULT_QUALITY compressionLevel = DEFAULT_COMPRESSION_LEVEL resetResult() } /** * 清除当前加载的图像(用于切换到另一张图片) */ fun clearSelectedImage() { selectedImage = null selectedImageFile = null selectedImageFileSize = 0L resetResult() } fun isAtDefaultParams(): Boolean { return selectedAlgorithm == DEFAULT_ALGORITHM && kotlin.math.abs(quality - DEFAULT_QUALITY) < 0.0001f && compressionLevel == DEFAULT_COMPRESSION_LEVEL } fun saveLastCompressedToFile(outputFile: File): ImageCompressionUtils.SaveResult? { val data = lastCompressedData ?: return null val params = lastCompressedParams ?: return null return ImageCompressionUtils.saveCompressedData( outputFile = outputFile, params = params, compressedData = data, usedFallback = lastCompressedUsedFallback ) } /** * 应用压缩后的图片到编辑器 */ fun applyCompressedImage(state: ApplicationState) { val image = compressedImage ?: return // 保存 Apply 前快照:用于压缩模块 Undo(不依赖全局队列) hasAppliedSnapshot = true lastAppliedPrevCurrent = state.currentImage lastAppliedPrevRaw = state.rawImage lastAppliedPrevFile = state.rawImageFile // 同时更新 rawImage 和 currentImage,保持与其他地方的一致性 state.rawImage = image state.currentImage = image // 压缩后的图片没有原始文件,设置为 null state.rawImageFile = null } fun undoApplied(state: ApplicationState): Boolean { if (!hasAppliedSnapshot) return false state.rawImage = lastAppliedPrevRaw state.currentImage = lastAppliedPrevCurrent state.rawImageFile = lastAppliedPrevFile hasAppliedSnapshot = false lastAppliedPrevCurrent = null lastAppliedPrevRaw = null lastAppliedPrevFile = null return true } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/CropAgent.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.* import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import cn.netdiscovery.monica.imageprocess.utils.extension.resize import cn.netdiscovery.monica.imageprocess.utils.extension.subImage import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropImageMask import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropOutline import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropPath import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropShape import org.jetbrains.skia.Matrix33 /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.CropAgent * @author: Tony Shen * @date: 2024/5/26 15:45 * @version: V1.0 <描述当前版本功能> */ class CropAgent { private val imagePaint = Paint().apply { blendMode = BlendMode.SrcIn } private val paint = Paint() fun crop( imageBitmap: ImageBitmap, cropRect: Rect, cropOutline: CropOutline, layoutDirection: LayoutDirection, density: Density, ): ImageBitmap { val imageToCrop = imageBitmap.toAwtImage().subImage(cropRect.left.toInt(),cropRect.top.toInt(),cropRect.width.toInt(),cropRect.height.toInt()).toComposeImageBitmap() drawCroppedImage(cropOutline, cropRect, layoutDirection, density, imageToCrop) return imageToCrop } private fun drawCroppedImage( cropOutline: CropOutline, cropRect: Rect, layoutDirection: LayoutDirection, density: Density, imageToCrop: ImageBitmap, ) { when (cropOutline) { is CropShape -> { val path = Path().apply { val outline = cropOutline.shape.createOutline(cropRect.size, layoutDirection, density) addOutline(outline) } Canvas(image = imageToCrop).run { saveLayer(cropRect, imagePaint) // Destination drawPath(path, paint) // Source drawImage( image = imageToCrop, topLeftOffset = Offset.Zero, paint = imagePaint ) restore() } } is CropPath -> { val path = Path().apply { addPath(cropOutline.path) val pathSize = getBounds().size val rectSize = cropRect.size val matrix = Matrix33.makeScale( rectSize.width / pathSize.width, cropRect.height / pathSize.height ) this.asSkiaPath().transform(matrix) val left = getBounds().left val top = getBounds().top translate(Offset(-left, -top)) } Canvas(image = imageToCrop).run { saveLayer(cropRect, imagePaint) // Destination drawPath(path, paint) // Source drawImage(image = imageToCrop, topLeftOffset = Offset.Zero, imagePaint) restore() } } is CropImageMask -> { val imageMask = cropOutline.image.toAwtImage().subImage(cropRect.left.toInt(),cropRect.top.toInt(),cropRect.width.toInt(),cropRect.height.toInt()).toComposeImageBitmap() Canvas(image = imageToCrop).run { saveLayer(cropRect, imagePaint) // Destination drawImage(imageMask, topLeftOffset = Offset.Zero, paint) // Source drawImage(image = imageToCrop, topLeftOffset = Offset.Zero, imagePaint) restore() } } } } fun resize( croppedImageBitmap: ImageBitmap, requiredWidth: Int, requiredHeight: Int ): ImageBitmap = croppedImageBitmap.toAwtImage().resize(requiredWidth,requiredHeight).toComposeImageBitmap() } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/CropImageSettingView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropFrameFactory import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.aspectRatios import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropOutlineProperty import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropProperties import cn.netdiscovery.monica.ui.widget.desktopLazyRow import cn.netdiscovery.monica.ui.widget.subTitle import cn.netdiscovery.monica.utils.OnCropPropertiesChange /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.CropImageSettingView * @author: Tony Shen * @date: 2024/6/10 21:32 * @version: V1.0 <描述当前版本功能> */ @Composable fun cropTypeSelect(cropProperties: CropProperties, onCropPropertiesChange: OnCropPropertiesChange) { subTitle(text = "Crop Type", fontWeight = FontWeight.Bold) var expanded by remember { mutableStateOf(false) } Column { Button(modifier = Modifier.width(180.dp).padding(top = 16.dp), onClick = { expanded = true }, enabled = true){ Text(text = cropTypes[cropTypesIndex.value].name, fontSize = 22.sp, color = Color.LightGray) } DropdownMenu(expanded= expanded, onDismissRequest = {expanded =false}){ cropTypes.forEachIndexed{ index, label -> DropdownMenuItem(onClick = { cropTypesIndex.value = index onCropPropertiesChange.invoke(cropProperties.copy(cropType = cropTypes[cropTypesIndex.value])) expanded = false }){ Text(text = label.name) } } } } } @Composable fun contentScaleSelect(cropProperties: CropProperties, onCropPropertiesChange: OnCropPropertiesChange) { subTitle(text = "Content Scale", fontWeight = FontWeight.Bold) var expanded by remember { mutableStateOf(false) } Column { Button(modifier = Modifier.width(180.dp).padding(top = 16.dp), onClick = { expanded = true }, enabled = true){ Text(text = contentScales[contentScalesIndex.value], fontSize = 22.sp, color = Color.LightGray) } DropdownMenu(expanded= expanded, onDismissRequest = {expanded =false}){ contentScales.forEachIndexed{ index, label -> DropdownMenuItem(onClick = { contentScalesIndex.value = index val scale = when (index) { 0 -> ContentScale.None 1 -> ContentScale.Fit 2 -> ContentScale.Crop 3 -> ContentScale.FillBounds 4 -> ContentScale.FillWidth 5 -> ContentScale.FillHeight else -> ContentScale.Inside } onCropPropertiesChange.invoke(cropProperties.copy(contentScale = scale)) expanded = false }){ Text(text = label) } } } } } @Composable fun aspectRatioScrollableRow(cropProperties: CropProperties, onCropPropertiesChange: OnCropPropertiesChange) { var selectRadio = remember { mutableStateOf("Original") } subTitle(text = "Aspect Ratio (${selectRadio.value})", fontWeight = FontWeight.Bold) desktopLazyRow { Card( elevation = 16.dp, modifier = Modifier.padding(start = 5.dp, top = 16.dp,end = 16.dp,bottom = 16.dp).clickable{ selectRadio.value = "Original" onCropPropertiesChange.invoke(cropProperties.copy(aspectRatio = aspectRatios[0].aspectRatio)) } ) { Text( text = "Original", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectRadio.value = "9:16" onCropPropertiesChange.invoke(cropProperties.copy(aspectRatio = aspectRatios[1].aspectRatio)) } ) { Text( text = "9:16", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectRadio.value = "2:3" onCropPropertiesChange.invoke(cropProperties.copy(aspectRatio = aspectRatios[2].aspectRatio)) } ) { Text( text = "2:3", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectRadio.value = "1:1" onCropPropertiesChange.invoke(cropProperties.copy(aspectRatio = aspectRatios[3].aspectRatio)) } ) { Text( text = "1:1", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectRadio.value = "16:9" onCropPropertiesChange.invoke(cropProperties.copy(aspectRatio = aspectRatios[4].aspectRatio)) } ) { Text( text = "16:9", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectRadio.value = "1.91:1" onCropPropertiesChange.invoke(cropProperties.copy(aspectRatio = aspectRatios[5].aspectRatio)) } ) { Text( text = "1.91:1", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectRadio.value = "3:2" onCropPropertiesChange.invoke(cropProperties.copy(aspectRatio = aspectRatios[6].aspectRatio)) } ) { Text( text = "3:2", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectRadio.value = "3:4" onCropPropertiesChange.invoke(cropProperties.copy(aspectRatio = aspectRatios[7].aspectRatio)) } ) { Text( text = "3:4", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectRadio.value = "3:5" onCropPropertiesChange.invoke(cropProperties.copy(aspectRatio = aspectRatios[8].aspectRatio)) } ) { Text( text = "3:5", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } } } @Composable fun cropFrameScrollableRow(cropProperties: CropProperties, cropFrameFactory: CropFrameFactory, onCropPropertiesChange: OnCropPropertiesChange) { var selectCropFrame = remember { mutableStateOf("Rect") } val cropFrames = cropFrameFactory.getCropFrames() subTitle(text = "Crop Frame (${selectCropFrame.value})", fontWeight = FontWeight.Bold) desktopLazyRow { Card( elevation = 16.dp, modifier = Modifier.padding(start = 5.dp, top = 16.dp, end = 16.dp, bottom = 16.dp).clickable { selectCropFrame.value = "Rect" val cropFrame = cropFrames[0] val cropOutlineProperty = CropOutlineProperty(cropFrame.outlineType, cropFrame.cropOutlineContainer.selectedItem) onCropPropertiesChange.invoke(cropProperties.copy(cropOutlineProperty = cropOutlineProperty)) } ) { Text( text = "Rect", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectCropFrame.value = "RoundedRect" val cropFrame = cropFrames[1] val cropOutlineProperty = CropOutlineProperty(cropFrame.outlineType, cropFrame.cropOutlineContainer.selectedItem) onCropPropertiesChange.invoke(cropProperties.copy(cropOutlineProperty = cropOutlineProperty)) } ) { Text( text = "RoundedRect", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectCropFrame.value = "CutCorner" val cropFrame = cropFrames[2] val cropOutlineProperty = CropOutlineProperty(cropFrame.outlineType, cropFrame.cropOutlineContainer.selectedItem) onCropPropertiesChange.invoke(cropProperties.copy(cropOutlineProperty = cropOutlineProperty)) } ) { Text( text = "CutCorner", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectCropFrame.value = "Oval" val cropFrame = cropFrames[3] val cropOutlineProperty = CropOutlineProperty(cropFrame.outlineType, cropFrame.cropOutlineContainer.selectedItem) onCropPropertiesChange.invoke(cropProperties.copy(cropOutlineProperty = cropOutlineProperty)) } ) { Text( text = "Oval", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectCropFrame.value = "Triangle" val cropFrame = cropFrames[4] val cropOutlineProperty = CropOutlineProperty(cropFrame.outlineType, cropFrame.cropOutlineContainer.outlines[1]) onCropPropertiesChange.invoke(cropProperties.copy(cropOutlineProperty = cropOutlineProperty)) } ) { Text( text = "Triangle", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectCropFrame.value = "Polygon" val cropFrame = cropFrames[4] val cropOutlineProperty = CropOutlineProperty(cropFrame.outlineType, cropFrame.cropOutlineContainer.selectedItem) onCropPropertiesChange.invoke(cropProperties.copy(cropOutlineProperty = cropOutlineProperty)) } ) { Text( text = "Polygon", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectCropFrame.value = "Parallelogram" val cropFrame = cropFrames[5] val cropOutlineProperty = CropOutlineProperty(cropFrame.outlineType, cropFrame.cropOutlineContainer.selectedItem) onCropPropertiesChange.invoke(cropProperties.copy(cropOutlineProperty = cropOutlineProperty)) } ) { Text( text = "Parallelogram", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectCropFrame.value = "Diamond" val cropFrame = cropFrames[6] val cropOutlineProperty = CropOutlineProperty(cropFrame.outlineType, cropFrame.cropOutlineContainer.selectedItem) onCropPropertiesChange.invoke(cropProperties.copy(cropOutlineProperty = cropOutlineProperty)) } ) { Text( text = "Diamond", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectCropFrame.value = "Ticket" val cropFrame = cropFrames[7] val cropOutlineProperty = CropOutlineProperty(cropFrame.outlineType, cropFrame.cropOutlineContainer.selectedItem) onCropPropertiesChange.invoke(cropProperties.copy(cropOutlineProperty = cropOutlineProperty)) } ) { Text( text = "Ticket", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectCropFrame.value = "Heart" val cropFrame = cropFrames[8] val cropOutlineProperty = CropOutlineProperty(cropFrame.outlineType, cropFrame.cropOutlineContainer.selectedItem) onCropPropertiesChange.invoke(cropProperties.copy(cropOutlineProperty = cropOutlineProperty)) } ) { Text( text = "Heart", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } Card( elevation = 16.dp, modifier = Modifier.padding(16.dp).clickable { selectCropFrame.value = "Star" val cropFrame = cropFrames[8] val cropOutlineProperty = CropOutlineProperty(cropFrame.outlineType, cropFrame.cropOutlineContainer.outlines[1]) onCropPropertiesChange.invoke(cropProperties.copy(cropOutlineProperty = cropOutlineProperty)) } ) { Text( text = "Star", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier .padding(16.dp) .fillMaxWidth() ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/CropImageView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.AlertDialog import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toAwtImage import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.OutlineType import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.RectCropShape import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.* import cn.netdiscovery.monica.ui.widget.PageLifecycle import cn.netdiscovery.monica.ui.widget.rightSideMenuBar import cn.netdiscovery.monica.ui.widget.toolTipButton import cn.netdiscovery.monica.utils.OnCropPropertiesChange import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.CropImageView * @author: Tony Shen * @date: 2024/5/27 14:00 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) val cropTypes = mutableListOf(CropType.Dynamic, CropType.Static) var cropTypesIndex = mutableStateOf(0) val contentScales = listOf("None", "Fit", "Crop", "FillBounds", "FillWidth", "FillHeight", "Inside") var contentScalesIndex = mutableStateOf(1) @Composable fun cropImage(state: ApplicationState) { val cropViewModel: CropViewModel = koinInject() val handleSize: Float = LocalDensity.current.run { 20.dp.toPx() } var croppedImage by remember { mutableStateOf(null) } var crop by remember { mutableStateOf(false) } var showSettingDialog by remember { mutableStateOf(false) } var showCropDialog by remember { mutableStateOf(false) } var isCropping by remember { mutableStateOf(false) } var cropProperties by remember { mutableStateOf( CropDefaults.properties( cropOutlineProperty = CropOutlineProperty( OutlineType.Rect, RectCropShape(0, "Rect") ), handleSize = handleSize ) ) } var cropStyle by remember { mutableStateOf(CropDefaults.style()) } val imageBitmap = state.currentImage!!.toComposeImageBitmap() val cropFrameFactory = remember { CropFrameFactory( listOf(imageBitmap) ) } PageLifecycle( onInit = { logger.info("CropImageView 启动时初始化") }, onDisposeEffect = { logger.info("CropImageView 关闭时释放资源") cropViewModel.clearCropImageView() } ) Box( modifier = Modifier .fillMaxSize() .background(Color.DarkGray), contentAlignment = Alignment.Center ) { Column(modifier = Modifier.fillMaxSize()) { ImageCropper( modifier = Modifier .fillMaxWidth() .weight(1f), imageBitmap = imageBitmap, contentDescription = "Image Cropper", cropStyle = cropStyle, cropProperties = cropProperties, crop = crop, onCropStart = { isCropping = true }, onCropSuccess = { croppedImage = it isCropping = false crop = false showCropDialog = true } ) } rightSideMenuBar(modifier = Modifier.align(Alignment.CenterEnd)) { toolTipButton(text = "settings", painter = painterResource("images/cropimage/settings.png"), onClick = { showSettingDialog = true }) toolTipButton(text = "crop", painter = painterResource("images/cropimage/crop.png"), onClick = { crop = true }) } } if (showSettingDialog) { showCroppedImageSettingDialog( cropProperties, cropFrameFactory, onConfirm = { cropProperties = it showSettingDialog = false }, onDismiss = { showSettingDialog = false } ) } if (showCropDialog) { croppedImage?.let { showCroppedImageDialog(imageBitmap = it, onConfirm = { showCropDialog = !showCropDialog state.addQueue(state.currentImage!!) state.currentImage = it.toAwtImage() state.closePreviewWindow() croppedImage = null }, onDismiss = { showCropDialog = !showCropDialog croppedImage = null }) } } } @Composable private fun showCroppedImageSettingDialog(cropProperties: CropProperties, cropFrameFactory: CropFrameFactory, onConfirm: OnCropPropertiesChange, onDismiss: () -> Unit) { var tempProperties: CropProperties = cropProperties AlertDialog( onDismissRequest = onDismiss, text = { Column( verticalArrangement = Arrangement.Center ) { Text( modifier = Modifier.align(Alignment.CenterHorizontally), text = "Crop Properties Settings", color = MaterialTheme.colors.primary, fontSize = 32.sp, fontWeight = FontWeight.Bold ) divider() cropTypeSelect(tempProperties) { tempProperties = it } divider() contentScaleSelect(tempProperties) { tempProperties = it } divider() aspectRatioScrollableRow(tempProperties) { tempProperties = it } divider() cropFrameScrollableRow(tempProperties,cropFrameFactory) { tempProperties = it } } }, confirmButton = { TextButton( onClick = { onConfirm(tempProperties) } ) { Text("Confirm") } }, dismissButton = { TextButton( onClick = { onDismiss() } ) { Text("Dismiss") } } ) } @Composable private fun showCroppedImageDialog(imageBitmap: ImageBitmap, onConfirm: () -> Unit, onDismiss: () -> Unit) { AlertDialog( onDismissRequest = onDismiss, text = { Image( modifier = Modifier .fillMaxWidth() .aspectRatio(1f), contentScale = ContentScale.Fit, bitmap = imageBitmap, contentDescription = "result" ) }, confirmButton = { TextButton( onClick = { onConfirm() } ) { Text("Confirm") } }, dismissButton = { TextButton( onClick = { onDismiss() } ) { Text("Dismiss") } } ) } @Composable private fun divider() { Spacer(modifier = Modifier.padding(top = 15.dp, bottom = 15.dp)) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/CropModifier.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.debugInspectorInfo import cn.netdiscovery.monica.ui.controlpanel.cropimage.state.CropData import cn.netdiscovery.monica.ui.controlpanel.cropimage.state.CropState import cn.netdiscovery.monica.ui.controlpanel.cropimage.state.cropData import cn.netdiscovery.monica.ui.widget.image.gesture.detectMotionEventsAsList import cn.netdiscovery.monica.ui.widget.image.gesture.detectTransformGestures import cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.ZoomLevel import cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.getNextZoomLevel import cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.update import kotlinx.coroutines.launch /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.CropModifier * @author: Tony Shen * @date: 2024/5/26 15:29 * @version: V1.0 <描述当前版本功能> */ fun Modifier.crop( vararg keys: Any?, cropState: CropState, zoomOnDoubleTap: (ZoomLevel) -> Float = cropState.DefaultOnDoubleTap, onDown: ((CropData) -> Unit)? = null, onMove: ((CropData) -> Unit)? = null, onUp: ((CropData) -> Unit)? = null, onGestureStart: ((CropData) -> Unit)? = null, onGesture: ((CropData) -> Unit)? = null, onGestureEnd: ((CropData) -> Unit)? = null ) = composed( factory = { LaunchedEffect(key1 = cropState){ cropState.init() } val coroutineScope = rememberCoroutineScope() // Current Zoom level var zoomLevel by remember { mutableStateOf(ZoomLevel.Min) } val transformModifier = Modifier.pointerInput(*keys) { detectTransformGestures( consume = false, onGestureStart = { onGestureStart?.invoke(cropState.cropData) }, onGestureEnd = { coroutineScope.launch { cropState.onGestureEnd { onGestureEnd?.invoke(cropState.cropData) } } }, onGesture = { centroid, pan, zoom, rotate, mainPointer, pointerList -> coroutineScope.launch { cropState.onGesture( centroid = centroid, panChange = pan, zoomChange = zoom, rotationChange = rotate, mainPointer = mainPointer, changes = pointerList ) } onGesture?.invoke(cropState.cropData) mainPointer.consume() } ) } val tapModifier = Modifier.pointerInput(*keys) { detectTapGestures( onDoubleTap = { offset: Offset -> coroutineScope.launch { zoomLevel = getNextZoomLevel(zoomLevel) val newZoom = zoomOnDoubleTap(zoomLevel) cropState.onDoubleTap( offset = offset, zoom = newZoom ) { onGestureEnd?.invoke(cropState.cropData) } } } ) } val touchModifier = Modifier.pointerInput(*keys) { detectMotionEventsAsList( onDown = { coroutineScope.launch { cropState.onDown(it) onDown?.invoke(cropState.cropData) } }, onMove = { coroutineScope.launch { cropState.onMove(it) onMove?.invoke(cropState.cropData) } }, onUp = { coroutineScope.launch { cropState.onUp(it) onUp?.invoke(cropState.cropData) } } ) } val graphicsModifier = Modifier.graphicsLayer { this.update(cropState) } this.then( clipToBounds() .then(tapModifier) .then(transformModifier) .then(touchModifier) .then(graphicsModifier) ) }, inspectorInfo = debugInspectorInfo { name = "crop" // add name and value of each argument properties["keys"] = keys properties["onDown"] = onGestureStart properties["onMove"] = onGesture properties["onUp"] = onGestureEnd } ) internal val CropState.DefaultOnDoubleTap: (ZoomLevel) -> Float get() = { zoomLevel: ZoomLevel -> when (zoomLevel) { ZoomLevel.Min -> 1f ZoomLevel.Mid -> 3f.coerceIn(zoomMin, zoomMax) ZoomLevel.Max -> 5f.coerceAtLeast(zoomMax) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/CropViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage import cn.netdiscovery.monica.config.KEY_CROP_FIRST import cn.netdiscovery.monica.config.KEY_CROP_SECOND import cn.netdiscovery.monica.rxcache.rxCache import cn.netdiscovery.monica.utils.logger import org.slf4j.Logger /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.CropViewModel * @author: Tony Shen * @date: 2024/5/8 11:46 * @version: V1.0 <描述当前版本功能> */ class CropViewModel { private val logger: Logger = logger() fun clearCropImageView() { cropTypesIndex.value = 0 contentScalesIndex.value = 1 cropFlag1.set(false) cropFlag2.set(false) rxCache.remove(KEY_CROP_FIRST) rxCache.remove(KEY_CROP_SECOND) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/ImageCropper.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.animation.scaleIn import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import cn.netdiscovery.monica.config.KEY_CROP import cn.netdiscovery.monica.config.KEY_CROP_FIRST import cn.netdiscovery.monica.config.KEY_CROP_SECOND import cn.netdiscovery.monica.rxcache.rxCache import cn.netdiscovery.monica.ui.controlpanel.cropimage.draw.DrawingOverlay import cn.netdiscovery.monica.ui.controlpanel.cropimage.draw.ImageDrawCanvas import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropOutline import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropDefaults import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropProperties import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropStyle import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropType import cn.netdiscovery.monica.ui.controlpanel.cropimage.state.DynamicCropState import cn.netdiscovery.monica.ui.controlpanel.cropimage.state.rememberCropState import cn.netdiscovery.monica.ui.widget.image.ImageWithConstraints import cn.netdiscovery.monica.ui.widget.image.getScaledImageBitmap import com.safframework.kotlin.coroutines.Default import com.safframework.rxcache.domain.CacheStrategy import com.safframework.rxcache.ext.get import com.safframework.rxcache.ext.saveMemoryFunc import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import java.util.concurrent.atomic.AtomicBoolean /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.ImageCropper * @author: Tony Shen * @date: 2024/5/26 12:00 * @version: V1.0 <描述当前版本功能> */ val cropFlag1:AtomicBoolean = AtomicBoolean(false) val cropFlag2:AtomicBoolean = AtomicBoolean(false) @Composable fun ImageCropper( modifier: Modifier = Modifier, imageBitmap: ImageBitmap, contentDescription: String?, cropStyle: CropStyle = CropDefaults.style(), cropProperties: CropProperties, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, crop: Boolean = false, backgroundColor: Color = Color.Black, onCropStart: () -> Unit, onCropSuccess: (ImageBitmap) -> Unit, onDrawGrid: (DrawScope.(rect: Rect, strokeWidth: Float, color: Color) -> Unit)? = null, ) { ImageWithConstraints( modifier = modifier.clipToBounds(), contentScale = cropProperties.contentScale, contentDescription = contentDescription, filterQuality = filterQuality, imageBitmap = imageBitmap, drawImage = false ) { // No crop operation is applied by ScalableImage so rect points to bounds of original // bitmap val scaledImageBitmap = getScaledImageBitmap( imageWidth = imageWidth, imageHeight = imageHeight, rect = rect, bitmap = imageBitmap, contentScale = cropProperties.contentScale, ) // Container Dimensions val containerWidthPx = constraints.maxWidth val containerHeightPx = constraints.maxHeight val containerWidth: Dp val containerHeight: Dp // Bitmap Dimensions val bitmapWidth = scaledImageBitmap.width val bitmapHeight = scaledImageBitmap.height // Dimensions of Composable that displays Bitmap val imageWidthPx: Int val imageHeightPx: Int with(LocalDensity.current) { imageWidthPx = imageWidth.roundToPx() imageHeightPx = imageHeight.roundToPx() containerWidth = containerWidthPx.toDp() containerHeight = containerHeightPx.toDp() } val cropType = cropProperties.cropType val contentScale = cropProperties.contentScale val fixedAspectRatio = cropProperties.fixedAspectRatio val cropOutline = cropProperties.cropOutlineProperty.cropOutline // these keys are for resetting cropper when image width/height, contentScale or // overlay aspect ratio changes val resetKeys = getResetKeys( scaledImageBitmap, imageWidthPx, imageHeightPx, contentScale, cropType, fixedAspectRatio ) val cropState = rememberCropState( imageSize = IntSize(bitmapWidth, bitmapHeight), containerSize = IntSize(containerWidthPx, containerHeightPx), drawAreaSize = IntSize(imageWidthPx, imageHeightPx), cropProperties = cropProperties, keys = resetKeys ) val isHandleTouched by remember(cropState) { derivedStateOf { cropState is DynamicCropState && handlesTouched(cropState.touchRegion) } } val pressedStateColor = remember(cropStyle.backgroundColor){ cropStyle.backgroundColor .copy(cropStyle.backgroundColor.alpha * .7f) } val transparentColor by animateColorAsState( animationSpec = tween(300, easing = LinearEasing), targetValue = if (isHandleTouched) pressedStateColor else cropStyle.backgroundColor ) if (!cropFlag1.get()) { rxCache.saveMemoryFunc(KEY_CROP_FIRST) { cropFlag1.set(true) cropState.cropRect } } val cachedRect = rxCache.get(KEY_CROP_SECOND, CacheStrategy.MEMORY)?.data?: rxCache.get(KEY_CROP_FIRST, CacheStrategy.MEMORY)?.data if (cachedRect != cropState.cropRect) { if (!cropFlag2.get()) { rxCache.saveMemoryFunc(KEY_CROP_SECOND) { cropFlag2.set(true) cropState.cropRect } } else { rxCache.saveMemory(KEY_CROP, cropState.cropRect) } } // Crops image when user invokes crop operation Crop( crop, scaledImageBitmap, cropState.cropRect, cropOutline, onCropStart, onCropSuccess, cropProperties.requiredSize ) val imageModifier = Modifier .size(containerWidth, containerHeight) .crop( keys = resetKeys, cropState = cropState ) LaunchedEffect(key1 = cropProperties) { cropState.updateProperties(cropProperties) } /// Create a MutableTransitionState for the AnimatedVisibility. var visible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { delay(100) visible = true } ImageCropper( modifier = imageModifier, visible = visible, imageBitmap = imageBitmap, containerWidth = containerWidth, containerHeight = containerHeight, imageWidthPx = imageWidthPx, imageHeightPx = imageHeightPx, handleSize = cropProperties.handleSize, overlayRect = cropState.overlayRect, cropType = cropType, cropOutline = cropOutline, cropStyle = cropStyle, transparentColor = transparentColor, backgroundColor = backgroundColor, onDrawGrid = onDrawGrid, ) } } @OptIn(ExperimentalAnimationApi::class) @Composable private fun ImageCropper( modifier: Modifier, visible: Boolean, imageBitmap: ImageBitmap, containerWidth: Dp, containerHeight: Dp, imageWidthPx: Int, imageHeightPx: Int, handleSize: Float, cropType: CropType, cropOutline: CropOutline, cropStyle: CropStyle, overlayRect: Rect, transparentColor: Color, backgroundColor: Color, onDrawGrid: (DrawScope.(rect: Rect, strokeWidth: Float, color: Color) -> Unit)?, ) { Box( modifier = Modifier .fillMaxSize() .background(backgroundColor) ) { AnimatedVisibility( visible = visible, enter = scaleIn(tween(500)) ) { ImageCropperImpl( modifier = modifier, imageBitmap = imageBitmap, containerWidth = containerWidth, containerHeight = containerHeight, imageWidthPx = imageWidthPx, imageHeightPx = imageHeightPx, cropType = cropType, cropOutline = cropOutline, handleSize = handleSize, cropStyle = cropStyle, rectOverlay = overlayRect, transparentColor = transparentColor, onDrawGrid = onDrawGrid, ) } } } @Composable private fun ImageCropperImpl( modifier: Modifier, imageBitmap: ImageBitmap, containerWidth: Dp, containerHeight: Dp, imageWidthPx: Int, imageHeightPx: Int, cropType: CropType, cropOutline: CropOutline, handleSize: Float, cropStyle: CropStyle, transparentColor: Color, rectOverlay: Rect, onDrawGrid: (DrawScope.(rect: Rect, strokeWidth: Float, color: Color) -> Unit)?, ) { Box(contentAlignment = Alignment.Center) { // Draw Image ImageDrawCanvas( modifier = modifier, imageBitmap = imageBitmap, imageWidth = imageWidthPx, imageHeight = imageHeightPx ) val drawOverlay = cropStyle.drawOverlay val drawGrid = cropStyle.drawGrid val overlayColor = cropStyle.overlayColor val handleColor = cropStyle.handleColor val drawHandles = cropType == CropType.Dynamic val strokeWidth = cropStyle.strokeWidth DrawingOverlay( modifier = Modifier.size(containerWidth, containerHeight), drawOverlay = drawOverlay, rect = rectOverlay, cropOutline = cropOutline, drawGrid = drawGrid, overlayColor = overlayColor, handleColor = handleColor, strokeWidth = strokeWidth, drawHandles = drawHandles, handleSize = handleSize, transparentColor = transparentColor, onDrawGrid = onDrawGrid, ) } } @Composable private fun Crop( crop: Boolean, scaledImageBitmap: ImageBitmap, rect: Rect, cropOutline: CropOutline, onCropStart: () -> Unit, onCropSuccess: (ImageBitmap) -> Unit, requiredSize: IntSize?, ) { val cropRect = rxCache.get(KEY_CROP, CacheStrategy.MEMORY)?.data ?: rect val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current // Crop Agent is responsible for cropping image val cropAgent = remember { CropAgent() } LaunchedEffect(crop) { if (crop) { flow { val croppedImageBitmap = cropAgent.crop( scaledImageBitmap, cropRect, cropOutline, layoutDirection, density ) if (requiredSize != null) { emit( cropAgent.resize( croppedImageBitmap, requiredSize.width, requiredSize.height, ) ) } else { emit(croppedImageBitmap) } } .flowOn(Default) .onStart { onCropStart() delay(400) } .onEach { onCropSuccess(it) } .launchIn(this) } } } @Composable private fun getResetKeys( scaledImageBitmap: ImageBitmap, imageWidthPx: Int, imageHeightPx: Int, contentScale: ContentScale, cropType: CropType, fixedAspectRatio: Boolean, ) = remember( scaledImageBitmap, imageWidthPx, imageHeightPx, contentScale, cropType, fixedAspectRatio, ) { arrayOf( scaledImageBitmap, imageWidthPx, imageHeightPx, contentScale, cropType, fixedAspectRatio, ) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/TouchRegion.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.TouchRegion * @author: Tony Shen * @date: 2024/5/26 12:17 * @version: V1.0 <描述当前版本功能> */ enum class TouchRegion { TopLeft, TopRight, BottomLeft, BottomRight, Inside, None } fun handlesTouched(touchRegion: TouchRegion) = touchRegion == TouchRegion.TopLeft || touchRegion == TouchRegion.TopRight || touchRegion == TouchRegion.BottomLeft || touchRegion == TouchRegion.BottomRight ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/draw/ImageDrawCanvas.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.draw import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import kotlin.math.roundToInt /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.draw.ImageDrawCanvas * @author: Tony Shen * @date: 2024/5/26 15:37 * @version: V1.0 <描述当前版本功能> */ @Composable internal fun ImageDrawCanvas( modifier: Modifier, imageBitmap: ImageBitmap, imageWidth: Int, imageHeight: Int ) { Canvas(modifier = modifier) { val canvasWidth = size.width.roundToInt() val canvasHeight = size.height.roundToInt() drawImage( image = imageBitmap, srcSize = IntSize(imageBitmap.width, imageBitmap.height), dstSize = IntSize(imageWidth, imageHeight), dstOffset = IntOffset( x = (canvasWidth - imageWidth) / 2, y = (canvasHeight - imageHeight) / 2 ) ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/draw/Overlay.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.draw import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropImageMask import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropOutline import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropPath import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropShape import cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.drawGrid import cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.scaleAndTranslatePath import cn.netdiscovery.monica.utils.extensions.drawWithLayer /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.draw.Overlay * @author: Tony Shen * @date: 2024/5/26 15:38 * @version: V1.0 <描述当前版本功能> */ @Composable internal fun DrawingOverlay( modifier: Modifier, drawOverlay: Boolean, rect: Rect, cropOutline: CropOutline, drawGrid: Boolean, transparentColor: Color, overlayColor: Color, handleColor: Color, strokeWidth: Dp, drawHandles: Boolean, handleSize: Float, onDrawGrid: (DrawScope.(rect: Rect, strokeWidth: Float, color: Color) -> Unit)? ) { val density = LocalDensity.current val layoutDirection: LayoutDirection = LocalLayoutDirection.current val strokeWidthPx = LocalDensity.current.run { strokeWidth.toPx() } val pathHandles = remember { Path() } when (cropOutline) { is CropShape -> { val outline = remember(rect, cropOutline) { cropOutline.shape.createOutline(rect.size, layoutDirection, density) } DrawingOverlayImpl( modifier = modifier, drawOverlay = drawOverlay, rect = rect, drawGrid = drawGrid, transparentColor = transparentColor, overlayColor = overlayColor, handleColor = handleColor, strokeWidth = strokeWidthPx, drawHandles = drawHandles, handleSize = handleSize, pathHandles = pathHandles, outline = outline, onDrawGrid = onDrawGrid, ) } is CropPath -> { val path = remember(rect, cropOutline) { Path().apply { addPath(cropOutline.path) scaleAndTranslatePath(rect.width, rect.height) } } DrawingOverlayImpl( modifier = modifier, drawOverlay = drawOverlay, rect = rect, drawGrid = drawGrid, transparentColor = transparentColor, overlayColor = overlayColor, handleColor = handleColor, strokeWidth = strokeWidthPx, drawHandles = drawHandles, handleSize = handleSize, pathHandles = pathHandles, path = path, onDrawGrid = onDrawGrid, ) } is CropImageMask -> { val imageBitmap = cropOutline.image DrawingOverlayImpl( modifier = modifier, drawOverlay = drawOverlay, rect = rect, drawGrid = drawGrid, transparentColor = transparentColor, overlayColor = overlayColor, handleColor = handleColor, strokeWidth = strokeWidthPx, drawHandles = drawHandles, handleSize = handleSize, pathHandles = pathHandles, image = imageBitmap, onDrawGrid = onDrawGrid, ) } } } @Composable private fun DrawingOverlayImpl( modifier: Modifier, drawOverlay: Boolean, rect: Rect, drawGrid: Boolean, transparentColor: Color, overlayColor: Color, handleColor: Color, strokeWidth: Float, drawHandles: Boolean, handleSize: Float, pathHandles: Path, outline: Outline, onDrawGrid: (DrawScope.(rect: Rect, strokeWidth: Float, color: Color) -> Unit)?, ) { Canvas(modifier = modifier) { drawOverlay( drawOverlay, rect, drawGrid, transparentColor, overlayColor, handleColor, strokeWidth, drawHandles, handleSize, pathHandles, onDrawGrid, ) { drawCropOutline(outline = outline) } } } @Composable private fun DrawingOverlayImpl( modifier: Modifier, drawOverlay: Boolean, rect: Rect, drawGrid: Boolean, transparentColor: Color, overlayColor: Color, handleColor: Color, strokeWidth: Float, drawHandles: Boolean, handleSize: Float, pathHandles: Path, path: Path, onDrawGrid: (DrawScope.(rect: Rect, strokeWidth: Float, color: Color) -> Unit)?, ) { Canvas(modifier = modifier) { drawOverlay( drawOverlay, rect, drawGrid, transparentColor, overlayColor, handleColor, strokeWidth, drawHandles, handleSize, pathHandles, onDrawGrid, ) { drawCropPath(path) } } } @Composable private fun DrawingOverlayImpl( modifier: Modifier, drawOverlay: Boolean, rect: Rect, drawGrid: Boolean, transparentColor: Color, overlayColor: Color, handleColor: Color, strokeWidth: Float, drawHandles: Boolean, handleSize: Float, pathHandles: Path, image: ImageBitmap, onDrawGrid: (DrawScope.(rect: Rect, strokeWidth: Float, color: Color) -> Unit)?, ) { Canvas(modifier = modifier) { drawOverlay( drawOverlay, rect, drawGrid, transparentColor, overlayColor, handleColor, strokeWidth, drawHandles, handleSize, pathHandles, onDrawGrid, ) { drawCropImage(rect, image) } } } private fun DrawScope.drawOverlay( drawOverlay: Boolean, rect: Rect, drawGrid: Boolean, transparentColor: Color, overlayColor: Color, handleColor: Color, strokeWidth: Float, drawHandles: Boolean, handleSize: Float, pathHandles: Path, onDrawGrid: (DrawScope.(rect: Rect, strokeWidth: Float, color: Color) -> Unit)?, drawBlock: DrawScope.() -> Unit, ) { drawWithLayer { // Destination drawRect(transparentColor) // Source translate(left = rect.left, top = rect.top) { drawBlock() } if (drawGrid) { if (onDrawGrid != null) { onDrawGrid(rect, strokeWidth, overlayColor) } else { drawGrid( rect = rect, strokeWidth = strokeWidth, color = overlayColor, ) } } } if (drawOverlay) { drawRect( topLeft = rect.topLeft, size = rect.size, color = overlayColor, style = Stroke(width = strokeWidth) ) if (drawHandles) { pathHandles.apply { reset() updateHandlePath(rect, handleSize) } drawPath( path = pathHandles, color = handleColor, style = Stroke( width = strokeWidth * 2, cap = StrokeCap.Round, join = StrokeJoin.Round ) ) } } } private fun DrawScope.drawCropImage( rect: Rect, imageBitmap: ImageBitmap, blendMode: BlendMode = BlendMode.DstOut ) { drawImage( image = imageBitmap, dstSize = IntSize(rect.size.width.toInt(), rect.size.height.toInt()), blendMode = blendMode ) } private fun DrawScope.drawCropOutline( outline: Outline, blendMode: BlendMode = BlendMode.SrcOut ) { drawOutline( outline = outline, color = Color.Transparent, blendMode = blendMode ) } private fun DrawScope.drawCropPath( path: Path, blendMode: BlendMode = BlendMode.SrcOut ) { drawPath( path = path, color = Color.Transparent, blendMode = blendMode ) } private fun Path.updateHandlePath( rect: Rect, handleSize: Float ) { if (rect != Rect.Zero) { // Top left lines moveTo(rect.topLeft.x, rect.topLeft.y + handleSize) lineTo(rect.topLeft.x, rect.topLeft.y) lineTo(rect.topLeft.x + handleSize, rect.topLeft.y) // Top right lines moveTo(rect.topRight.x - handleSize, rect.topRight.y) lineTo(rect.topRight.x, rect.topRight.y) lineTo(rect.topRight.x, rect.topRight.y + handleSize) // Bottom right lines moveTo(rect.bottomRight.x, rect.bottomRight.y - handleSize) lineTo(rect.bottomRight.x, rect.bottomRight.y) lineTo(rect.bottomRight.x - handleSize, rect.bottomRight.y) // Bottom left lines moveTo(rect.bottomLeft.x + handleSize, rect.bottomLeft.y) lineTo(rect.bottomLeft.x, rect.bottomLeft.y) lineTo(rect.bottomLeft.x, rect.bottomLeft.y - handleSize) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/model/CropAspectRatio.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.model import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Shape import cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.createRectShape /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropAspectRatio * @author: Tony Shen * @date: 2024/6/10 17:29 * @version: V1.0 <描述当前版本功能> */ val aspectRatios = listOf( CropAspectRatio( title = "Original", shape = createRectShape(AspectRatio.Original), aspectRatio = AspectRatio.Original ), CropAspectRatio( title = "9:16", shape = createRectShape(AspectRatio(9 / 16f)), aspectRatio = AspectRatio(9 / 16f) ), CropAspectRatio( title = "2:3", shape = createRectShape(AspectRatio(2 / 3f)), aspectRatio = AspectRatio(2 / 3f) ), CropAspectRatio( title = "1:1", shape = createRectShape(AspectRatio(1 / 1f)), aspectRatio = AspectRatio(1 / 1f) ), CropAspectRatio( title = "16:9", shape = createRectShape(AspectRatio(16 / 9f)), aspectRatio = AspectRatio(16 / 9f) ), CropAspectRatio( title = "1.91:1", shape = createRectShape(AspectRatio(1.91f / 1f)), aspectRatio = AspectRatio(1.91f / 1f) ), CropAspectRatio( title = "3:2", shape = createRectShape(AspectRatio(3 / 2f)), aspectRatio = AspectRatio(3 / 2f) ), CropAspectRatio( title = "3:4", shape = createRectShape(AspectRatio(3 / 4f)), aspectRatio = AspectRatio(3 / 4f) ), CropAspectRatio( title = "3:5", shape = createRectShape(AspectRatio(3 / 5f)), aspectRatio = AspectRatio(3 / 5f) ) ) @Immutable data class CropAspectRatio( val title: String, val shape: Shape, val aspectRatio: AspectRatio = AspectRatio.Original, val icons: List = listOf() ) /** * Value class for containing aspect ratio * and [AspectRatio.Original] for comparing */ @Immutable data class AspectRatio(val value: Float) { companion object { val Original = AspectRatio(-1f) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/model/CropFrame.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.model import androidx.compose.runtime.Immutable /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropFrame * @author: Tony Shen * @date: 2024/5/26 15:23 * @version: V1.0 <描述当前版本功能> */ @Immutable data class CropFrame( val outlineType: OutlineType, val editable: Boolean = false, val cropOutlineContainer: CropOutlineContainer ) { var selectedIndex: Int get() = cropOutlineContainer.selectedIndex set(value) { cropOutlineContainer.selectedIndex = value } val outlines: List get() = cropOutlineContainer.outlines val outlineCount: Int get() = cropOutlineContainer.size fun addOutline(outline: CropOutline): CropFrame { outlines.toMutableList().add(outline) return this } } @Suppress("UNCHECKED_CAST") fun getOutlineContainer( outlineType: OutlineType, index: Int, outlines: List ): CropOutlineContainer { return when (outlineType) { OutlineType.RoundedRect -> { RoundedRectOutlineContainer( selectedIndex = index, outlines = outlines as List ) } OutlineType.CutCorner -> { CutCornerRectOutlineContainer( selectedIndex = index, outlines = outlines as List ) } OutlineType.Oval -> { OvalOutlineContainer( selectedIndex = index, outlines = outlines as List ) } OutlineType.Polygon -> { PolygonOutlineContainer( selectedIndex = index, outlines = outlines as List ) } OutlineType.Diamond -> { DiamondOutlineContainer( selectedIndex = index, outlines = outlines as List ) } OutlineType.Ticket -> { TicketOutlineContainer( selectedIndex = index, outlines = outlines as List ) } OutlineType.Custom -> { CustomOutlineContainer( selectedIndex = index, outlines = outlines as List ) } OutlineType.ImageMask -> { ImageMaskOutlineContainer( selectedIndex = index, outlines = outlines as List ) } else -> { RectOutlineContainer( selectedIndex = index, outlines = outlines as List ) } } } enum class OutlineType { Rect, RoundedRect, CutCorner, Oval, Polygon, Parallelogram, Diamond, Ticket, Custom, ImageMask } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/model/CropOutline.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.model import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.Diamond import cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.Parallelogram import cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.Ticket import cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.createPolygonShape /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropOutline * @author: Tony Shen * @date: 2024/5/26 12:22 * @version: V1.0 <描述当前版本功能> */ interface CropOutline { val id: Int val title: String } /** * Crop outline that contains a [Shape] like [RectangleShape] to draw frame for cropping */ interface CropShape : CropOutline { val shape: Shape } /** * Crop outline that contains a [Path] to draw frame for cropping */ interface CropPath : CropOutline { val path: Path } /** * Crop outline that contains a [ImageBitmap] to draw frame for cropping. And blend modes * to draw */ interface CropImageMask : CropOutline { val image: ImageBitmap } /** * Wrapper class that implements [CropOutline] and is a shape * wrapper that contains [RectangleShape] */ @Immutable data class RectCropShape( override val id: Int, override val title: String, ) : CropShape { override val shape: Shape = RectangleShape } /** * Wrapper class that implements [CropOutline] and is a shape * wrapper that contains [RoundedCornerShape] */ @Immutable data class RoundedCornerCropShape( override val id: Int, override val title: String, val cornerRadius: CornerRadiusProperties = CornerRadiusProperties(), override val shape: RoundedCornerShape = RoundedCornerShape( topStartPercent = cornerRadius.topStartPercent, topEndPercent = cornerRadius.topEndPercent, bottomEndPercent = cornerRadius.bottomEndPercent, bottomStartPercent = cornerRadius.bottomStartPercent ) ) : CropShape /** * Wrapper class that implements [CropOutline] and is a shape * wrapper that contains [CutCornerShape] */ @Immutable data class CutCornerCropShape( override val id: Int, override val title: String, val cornerRadius: CornerRadiusProperties = CornerRadiusProperties(), override val shape: CutCornerShape = CutCornerShape( topStartPercent = cornerRadius.topStartPercent, topEndPercent = cornerRadius.topEndPercent, bottomEndPercent = cornerRadius.bottomEndPercent, bottomStartPercent = cornerRadius.bottomStartPercent ) ) : CropShape /** * Wrapper class that implements [CropOutline] and is a shape * wrapper that contains [CircleShape] */ @Immutable data class OvalCropShape( override val id: Int, override val title: String, val ovalProperties: OvalProperties = OvalProperties(), override val shape: Shape = CircleShape ) : CropShape /** * Wrapper class that implements [CropOutline] and is a shape * wrapper that contains [CircleShape] */ @Immutable data class PolygonCropShape( override val id: Int, override val title: String, val polygonProperties: PolygonProperties = PolygonProperties(), override val shape: Shape = createPolygonShape(polygonProperties.sides, polygonProperties.angle) ) : CropShape @Immutable data class ParallelogramShape( override val id: Int, override val title: String, override val shape: Shape = Parallelogram(70f) ): CropShape @Immutable data class DiamondShape( override val id: Int, override val title: String, override val shape: Shape = Diamond() ): CropShape @Immutable data class TicketShape( override val id: Int, override val title: String, override val shape: Shape = Ticket() ): CropShape /** * Wrapper class that implements [CropOutline] and is a [Path] wrapper to crop using drawable * files converted fom svg or Vector Drawable to [Path] */ @Immutable data class CustomPathOutline( override val id: Int, override val title: String, override val path: Path ) : CropPath /** * Wrapper class that implements [CropOutline] and is a [ImageBitmap] wrapper to crop * using a reference png and blend modes to crop */ @Immutable data class ImageMaskOutline( override val id: Int, override val title: String, override val image: ImageBitmap, ) : CropImageMask ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/model/CropOutlineContainer.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.model /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropOutlineContainer * @author: Tony Shen * @date: 2024/5/26 15:24 * @version: V1.0 <描述当前版本功能> */ interface CropOutlineContainer { var selectedIndex: Int val outlines: List val selectedItem: O get() = outlines[selectedIndex] val size: Int get() = outlines.size } /** * Container for [RectCropShape] */ data class RectOutlineContainer( override var selectedIndex: Int = 0, override val outlines: List ) : CropOutlineContainer /** * Container for [RoundedCornerCropShape]s */ data class RoundedRectOutlineContainer( override var selectedIndex: Int = 0, override val outlines: List ) : CropOutlineContainer /** * Container for [CutCornerCropShape]s */ data class CutCornerRectOutlineContainer( override var selectedIndex: Int = 0, override val outlines: List ) : CropOutlineContainer /** * Container for [OvalCropShape]s */ data class OvalOutlineContainer( override var selectedIndex: Int = 0, override val outlines: List ) : CropOutlineContainer /** * Container for [PolygonCropShape]s */ data class PolygonOutlineContainer( override var selectedIndex: Int = 0, override val outlines: List ) : CropOutlineContainer /** * Container for [ParallelogramShape]s */ data class ParallelogramOutlineContainer( override var selectedIndex: Int = 0, override val outlines: List ) : CropOutlineContainer /** * Container for [DiamondShape]s */ data class DiamondOutlineContainer( override var selectedIndex: Int = 0, override val outlines: List ) : CropOutlineContainer /** * Container for [TicketShape]s */ data class TicketOutlineContainer( override var selectedIndex: Int = 0, override val outlines: List ) : CropOutlineContainer /** * Container for [CustomPathOutline]s */ data class CustomOutlineContainer( override var selectedIndex: Int = 0, override val outlines: List ) : CropOutlineContainer /** * Container for [ImageMaskOutline]s */ data class ImageMaskOutlineContainer( override var selectedIndex: Int = 0, override val outlines: List ) : CropOutlineContainer ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/model/CropOutlineProperties.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.model import androidx.compose.runtime.Immutable import androidx.compose.ui.geometry.Offset /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropOutlineProperties * @author: Tony Shen * @date: 2024/5/26 12:23 * @version: V1.0 <描述当前版本功能> */ @Immutable data class CornerRadiusProperties( val topStartPercent: Int = 20, val topEndPercent: Int = 20, val bottomStartPercent: Int = 20, val bottomEndPercent: Int = 20 ) @Immutable data class PolygonProperties( val sides: Int = 6, val angle: Float = 0f, val offset: Offset = Offset.Zero ) @Immutable data class OvalProperties( val startAngle: Float = 0f, val sweepAngle: Float = 360f, val offset: Offset = Offset.Zero ) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/setting/CropDefaults.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.setting import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.AspectRatio import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.CropOutline import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.OutlineType import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.aspectRatios /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropDefaults * @author: Tony Shen * @date: 2024/5/26 12:20 * @version: V1.0 <描述当前版本功能> */ enum class CropType { Static, Dynamic } object CropDefaults { val DefaultBackgroundColor = Color(0x99000000) val DefaultOverlayColor = Color.Gray val DefaultHandleColor = Color.White /** * Properties effect crop behavior that should be passed to [CropState] */ fun properties( cropType: CropType = CropType.Dynamic, handleSize: Float, maxZoom: Float = 10f, contentScale: ContentScale = ContentScale.Fit, cropOutlineProperty: CropOutlineProperty, aspectRatio: AspectRatio = aspectRatios[2].aspectRatio, overlayRatio: Float = .9f, pannable: Boolean = true, fling: Boolean = false, zoomable: Boolean = true, rotatable: Boolean = false, fixedAspectRatio: Boolean = false, requiredSize: IntSize? = null, minDimension: IntSize? = null, ): CropProperties { return CropProperties( cropType = cropType, handleSize = handleSize, contentScale = contentScale, cropOutlineProperty = cropOutlineProperty, maxZoom = maxZoom, aspectRatio = aspectRatio, overlayRatio = overlayRatio, pannable = pannable, fling = fling, zoomable = zoomable, rotatable = rotatable, fixedAspectRatio = fixedAspectRatio, requiredSize = requiredSize, minDimension = minDimension, ) } /** * Style is cosmetic changes that don't effect how [CropState] behaves because of that * none of these properties are passed to [CropState] */ fun style( drawOverlay: Boolean = true, drawGrid: Boolean = true, strokeWidth: Dp = 1.dp, overlayColor: Color = DefaultOverlayColor, handleColor: Color = DefaultHandleColor, backgroundColor: Color = DefaultBackgroundColor ): CropStyle { return CropStyle( drawOverlay = drawOverlay, drawGrid = drawGrid, strokeWidth = strokeWidth, overlayColor = overlayColor, handleColor = handleColor, backgroundColor = backgroundColor ) } } /** * Data class for selecting cropper properties. Fields of this class control inner work * of [CropState] while some such as [cropType], [aspectRatio], [handleSize] * is shared between ui and state. */ @Immutable data class CropProperties internal constructor( val cropType: CropType, val handleSize: Float, val contentScale: ContentScale, val cropOutlineProperty: CropOutlineProperty, val aspectRatio: AspectRatio, val overlayRatio: Float, val pannable: Boolean, val fling: Boolean, val rotatable: Boolean, val zoomable: Boolean, val maxZoom: Float, val fixedAspectRatio: Boolean = false, val requiredSize: IntSize? = null, val minDimension: IntSize? = null, ) /** * Data class for cropper styling only. None of the properties of this class is used * by [CropState] or [Modifier.crop] */ @Immutable data class CropStyle internal constructor( val drawOverlay: Boolean, val drawGrid: Boolean, val strokeWidth: Dp, val overlayColor: Color, val handleColor: Color, val backgroundColor: Color, val cropTheme: CropTheme = CropTheme.Dark ) /** * Property for passing [CropOutline] between settings UI to [ImageCropper] */ @Immutable data class CropOutlineProperty( val outlineType: OutlineType, val cropOutline: CropOutline ) /** * Light, Dark or system controlled theme */ enum class CropTheme{ Light, Dark, System } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/setting/CropFrameFactory.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.setting import androidx.compose.runtime.mutableStateListOf import androidx.compose.ui.graphics.ImageBitmap import cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.createPolygonShape import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.* /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropFrameFactory * @author: Tony Shen * @date: 2024/5/28 18:27 * @version: V1.0 <描述当前版本功能> */ class CropFrameFactory(private val defaultImages: List) { private val cropFrames = mutableStateListOf() fun getCropFrames(): List { if (cropFrames.isEmpty()) { val temp = mutableListOf() OutlineType.values().forEach { temp.add(getCropFrame(it)) } cropFrames.addAll(temp) } return cropFrames } fun getCropFrame(outlineType: OutlineType): CropFrame { return cropFrames .firstOrNull { it.outlineType == outlineType } ?: createDefaultFrame(outlineType) } private fun createDefaultFrame(outlineType: OutlineType): CropFrame { return when (outlineType) { OutlineType.Rect -> { CropFrame( outlineType = outlineType, editable = false, cropOutlineContainer = createCropOutlineContainer(outlineType) ) } OutlineType.RoundedRect -> { CropFrame( outlineType = outlineType, editable = true, cropOutlineContainer = createCropOutlineContainer(outlineType) ) } OutlineType.CutCorner -> { CropFrame( outlineType = outlineType, editable = true, cropOutlineContainer = createCropOutlineContainer(outlineType) ) } OutlineType.Oval -> { CropFrame( outlineType = outlineType, editable = true, cropOutlineContainer = createCropOutlineContainer(outlineType) ) } OutlineType.Polygon -> { CropFrame( outlineType = outlineType, editable = true, cropOutlineContainer = createCropOutlineContainer(outlineType) ) } OutlineType.Parallelogram -> { CropFrame( outlineType = outlineType, editable = true, cropOutlineContainer = createCropOutlineContainer(outlineType) ) } OutlineType.Diamond -> { CropFrame( outlineType = outlineType, editable = true, cropOutlineContainer = createCropOutlineContainer(outlineType) ) } OutlineType.Ticket -> { CropFrame( outlineType = outlineType, editable = true, cropOutlineContainer = createCropOutlineContainer(outlineType) ) } OutlineType.Custom -> { CropFrame( outlineType = outlineType, editable = true, cropOutlineContainer = createCropOutlineContainer(outlineType) ) } OutlineType.ImageMask -> { CropFrame( outlineType = outlineType, editable = true, cropOutlineContainer = createCropOutlineContainer(outlineType) ) } } } private fun createCropOutlineContainer( outlineType: OutlineType ): CropOutlineContainer { return when (outlineType) { OutlineType.Rect -> { RectOutlineContainer( outlines = listOf(RectCropShape(id = 0, title = "Rect")) ) } OutlineType.RoundedRect -> { RoundedRectOutlineContainer( outlines = listOf(RoundedCornerCropShape(id = 0, title = "Rounded")) ) } OutlineType.CutCorner -> { CutCornerRectOutlineContainer( outlines = listOf(CutCornerCropShape(id = 0, title = "CutCorner")) ) } OutlineType.Oval -> { OvalOutlineContainer( outlines = listOf(OvalCropShape(id = 0, title = "Oval")) ) } OutlineType.Polygon -> { PolygonOutlineContainer( outlines = listOf( PolygonCropShape( id = 0, title = "Polygon" ), PolygonCropShape( id = 1, title = "Triangle", polygonProperties = PolygonProperties(sides = 3, 0f), shape = createPolygonShape(3, 30f) ), PolygonCropShape( id = 2, title = "Pentagon", polygonProperties = PolygonProperties(sides = 5, 0f), shape = createPolygonShape(5, 0f) ), PolygonCropShape( id = 3, title = "Heptagon", polygonProperties = PolygonProperties(sides = 7, 0f), shape = createPolygonShape(7, 0f) ), PolygonCropShape( id = 4, title = "Octagon", polygonProperties = PolygonProperties(sides = 8, 0f), shape = createPolygonShape(8, 0f) ) ) ) } OutlineType.Parallelogram -> { ParallelogramOutlineContainer( outlines = listOf(ParallelogramShape(id = 0, title = "Parallelogram")) ) } OutlineType.Diamond -> { DiamondOutlineContainer( outlines = listOf(DiamondShape(id = 0, title = "Diamond")) ) } OutlineType.Ticket -> { TicketOutlineContainer( outlines = listOf(TicketShape(id = 0, title = "Ticket")) ) } OutlineType.Custom -> { CustomOutlineContainer( outlines = listOf( CustomPathOutline(id = 0, title = "Custom", path = Paths.Favorite), CustomPathOutline(id = 1, title = "Star", path = Paths.Star) ) ) } OutlineType.ImageMask -> { val outlines = defaultImages.mapIndexed { index, image -> ImageMaskOutline(id = index, title = "ImageMask", image = image) } ImageMaskOutlineContainer( outlines = outlines ) } } } fun editCropFrame(cropFrame: CropFrame) { val indexOf = cropFrames.indexOfFirst { it.outlineType == cropFrame.outlineType } cropFrames[indexOf] = cropFrame } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/setting/Paths.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.setting import androidx.compose.ui.graphics.Path /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.Paths * @author: Tony Shen * @date: 2024/5/28 18:29 * @version: V1.0 <描述当前版本功能> */ object Paths { val Favorite get() = Path().apply { moveTo(12.0f, 21.35f) relativeLineTo(-1.45f, -1.32f) cubicTo(5.4f, 15.36f, 2.0f, 12.28f, 2.0f, 8.5f) cubicTo(2.0f, 5.42f, 4.42f, 3.0f, 7.5f, 3.0f) relativeCubicTo(1.74f, 0.0f, 3.41f, 0.81f, 4.5f, 2.09f) cubicTo(13.09f, 3.81f, 14.76f, 3.0f, 16.5f, 3.0f) cubicTo(19.58f, 3.0f, 22.0f, 5.42f, 22.0f, 8.5f) relativeCubicTo(0.0f, 3.78f, -3.4f, 6.86f, -8.55f, 11.54f) lineTo(12.0f, 21.35f) close() } val Star = Path().apply { moveTo(12.0f, 17.27f) lineTo(18.18f, 21.0f) relativeLineTo(-1.64f, -7.03f) lineTo(22.0f, 9.24f) relativeLineTo(-7.19f, -0.61f) lineTo(12.0f, 2.0f) lineTo(9.19f, 8.63f) lineTo(2.0f, 9.24f) relativeLineTo(5.46f, 4.73f) lineTo(5.82f, 21.0f) close() } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/state/CropState.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.state import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.unit.IntSize import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropProperties import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropType /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.state.CropState * @author: Tony Shen * @date: 2024/5/26 12:04 * @version: V1.0 <描述当前版本功能> */ @Composable fun rememberCropState( imageSize: IntSize, containerSize: IntSize, drawAreaSize: IntSize, cropProperties: CropProperties, vararg keys: Any? ): CropState { // Properties of crop state val handleSize = cropProperties.handleSize val cropType = cropProperties.cropType val aspectRatio = cropProperties.aspectRatio val overlayRatio = cropProperties.overlayRatio val maxZoom = cropProperties.maxZoom val fling = cropProperties.fling val zoomable = cropProperties.zoomable val pannable = cropProperties.pannable val rotatable = cropProperties.rotatable val fixedAspectRatio = cropProperties.fixedAspectRatio val minDimension = cropProperties.minDimension return remember(*keys) { when (cropType) { CropType.Static -> { StaticCropState( imageSize = imageSize, containerSize = containerSize, drawAreaSize = drawAreaSize, aspectRatio = aspectRatio, overlayRatio = overlayRatio, maxZoom = maxZoom, fling = fling, zoomable = zoomable, pannable = pannable, rotatable = rotatable, limitPan = false ) } else -> { DynamicCropState( imageSize = imageSize, containerSize = containerSize, drawAreaSize = drawAreaSize, aspectRatio = aspectRatio, overlayRatio = overlayRatio, maxZoom = maxZoom, handleSize = handleSize, fling = fling, zoomable = zoomable, pannable = pannable, rotatable = rotatable, limitPan = true, fixedAspectRatio = fixedAspectRatio, minDimension = minDimension, ) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/state/CropStateImpl.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.state import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.tween import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.unit.IntSize import cn.netdiscovery.monica.ui.controlpanel.cropimage.TouchRegion import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.AspectRatio import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropProperties /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.state.CropStateImpl * @author: Tony Shen * @date: 2024/5/26 12:05 * @version: V1.0 <描述当前版本功能> */ @Immutable data class CropData( val zoom: Float = 1f, val pan: Offset = Offset.Zero, val rotation: Float = 0f, val overlayRect: Rect, val cropRect: Rect ) val CropState.cropData: CropData get() = CropData( zoom = animatableZoom.targetValue, pan = Offset(animatablePanX.targetValue, animatablePanY.targetValue), rotation = animatableRotation.targetValue, overlayRect = overlayRect, cropRect = cropRect ) abstract class CropState internal constructor( imageSize: IntSize, containerSize: IntSize, drawAreaSize: IntSize, maxZoom: Float, internal var fling: Boolean = true, internal var aspectRatio: AspectRatio, internal var overlayRatio: Float, zoomable: Boolean = true, pannable: Boolean = true, rotatable: Boolean = false, limitPan: Boolean = false ) : TransformState( imageSize = imageSize, containerSize = containerSize, drawAreaSize = drawAreaSize, initialZoom = 1f, initialRotation = 0f, maxZoom = maxZoom, zoomable = zoomable, pannable = pannable, rotatable = rotatable, limitPan = limitPan ) { private val animatableRectOverlay = Animatable( getOverlayFromAspectRatio( containerSize.width.toFloat(), containerSize.height.toFloat(), drawAreaSize.width.toFloat(), aspectRatio, overlayRatio ), Rect.VectorConverter ) val overlayRect: Rect get() = animatableRectOverlay.value var cropRect: Rect = Rect.Zero get() = getCropRectangle( imageSize.width, imageSize.height, drawAreaRect, animatableRectOverlay.targetValue ) private set private var initialized: Boolean = false /** * Region of touch inside, corners of or outside of overlay rectangle */ var touchRegion by mutableStateOf(TouchRegion.None) internal suspend fun init() { // When initial aspect ratio doesn't match drawable area // overlay gets updated so updates draw area as well animateTransformationToOverlayBounds(overlayRect, animate = true) initialized = true } /** * Update properties of [CropState] and animate to valid intervals if required */ internal open suspend fun updateProperties( cropProperties: CropProperties, forceUpdate: Boolean = false ) { if (!initialized) return fling = cropProperties.fling pannable = cropProperties.pannable zoomable = cropProperties.zoomable rotatable = cropProperties.rotatable val maxZoom = cropProperties.maxZoom // Update overlay rectangle val aspectRatio = cropProperties.aspectRatio // Ratio of overlay to screen val overlayRatio = cropProperties.overlayRatio if ( this.aspectRatio.value != aspectRatio.value || maxZoom != zoomMax || this.overlayRatio != overlayRatio || forceUpdate ) { this.aspectRatio = aspectRatio this.overlayRatio = overlayRatio zoomMax = maxZoom animatableZoom.updateBounds(zoomMin, zoomMax) val currentZoom = if (zoom > zoomMax) zoomMax else zoom // Set new zoom snapZoomTo(currentZoom) // Calculate new region of image is drawn. It can be drawn left of 0 and right // of container width depending on transformation drawAreaRect = updateImageDrawRectFromTransformation() // Update overlay rectangle based on current draw area and new aspect ratio animateOverlayRectTo( getOverlayFromAspectRatio( containerSize.width.toFloat(), containerSize.height.toFloat(), drawAreaSize.width.toFloat(), aspectRatio, overlayRatio ) ) } // Animate zoom, pan, rotation to move draw area to cover overlay rect // inside draw area rect animateTransformationToOverlayBounds(overlayRect, animate = true) } /** * Animate overlay rectangle to target value */ internal suspend fun animateOverlayRectTo( rect: Rect, animationSpec: AnimationSpec = tween(400) ) { animatableRectOverlay.animateTo( targetValue = rect, animationSpec = animationSpec ) } /** * Snap overlay rectangle to target value */ internal suspend fun snapOverlayRectTo(rect: Rect) { animatableRectOverlay.snapTo(rect) } /* Touch gestures */ internal abstract suspend fun onDown(change: PointerInputChange) internal abstract suspend fun onMove(changes: List) internal abstract suspend fun onUp(change: PointerInputChange) /* Transform gestures */ internal abstract suspend fun onGesture( centroid: Offset, panChange: Offset, zoomChange: Float, rotationChange: Float, mainPointer: PointerInputChange, changes: List ) internal abstract suspend fun onGestureStart() internal abstract suspend fun onGestureEnd(onBoundsCalculated: () -> Unit) // Double Tap internal abstract suspend fun onDoubleTap( offset: Offset, zoom: Float = 1f, onAnimationEnd: () -> Unit ) /** * Check if area that image is drawn covers [overlayRect] */ internal fun isOverlayInImageDrawBounds(): Boolean { return drawAreaRect.left <= overlayRect.left && drawAreaRect.top <= overlayRect.top && drawAreaRect.right >= overlayRect.right && drawAreaRect.bottom >= overlayRect.bottom } /** * Check if [rect] is inside container bounds */ internal fun isRectInContainerBounds(rect: Rect): Boolean { return rect.left >= 0 && rect.right <= containerSize.width && rect.top >= 0 && rect.bottom <= containerSize.height } /** * Update rectangle for area that image is drawn. This rect changes when zoom and * pan changes and position of image changes on screen as result of transformation. * * This function is called * * * when [onGesture] is called to update rect when zoom or pan changes * and if [fling] is true just after **fling** gesture starts with target * value in [StaticCropState]. * * * when [updateProperties] is called in [CropState] * * * when [onUp] is called in [DynamicCropState] to match [overlayRect] that could be * changed and animated if it's out of [containerSize] bounds or its grow * bigger than previous size */ internal fun updateImageDrawRectFromTransformation(): Rect { val containerWidth = containerSize.width val containerHeight = containerSize.height val originalDrawWidth = drawAreaSize.width val originalDrawHeight = drawAreaSize.height val panX = animatablePanX.targetValue val panY = animatablePanY.targetValue val left = (containerWidth - originalDrawWidth) / 2 val top = (containerHeight - originalDrawHeight) / 2 val zoom = animatableZoom.targetValue val newWidth = originalDrawWidth * zoom val newHeight = originalDrawHeight * zoom return Rect( offset = Offset( left - (newWidth - originalDrawWidth) / 2 + panX, top - (newHeight - originalDrawHeight) / 2 + panY, ), size = Size(newWidth, newHeight) ) } // TODO Add resetting back to bounds for rotated state as well /** * Resets to bounds with animation and resets tracking for fling animation. * Changes pan, zoom and rotation to valid bounds based on [drawAreaRect] and [overlayRect] */ internal suspend fun animateTransformationToOverlayBounds( overlayRect: Rect, animate: Boolean, animationSpec: AnimationSpec = tween(400) ) { val zoom = zoom.coerceAtLeast(1f) // Calculate new pan based on overlay val newDrawAreaRect = calculateValidImageDrawRect(overlayRect, drawAreaRect) val newZoom = calculateNewZoom(oldRect = drawAreaRect, newRect = newDrawAreaRect, zoom = zoom) val leftChange = newDrawAreaRect.left - drawAreaRect.left val topChange = newDrawAreaRect.top - drawAreaRect.top val widthChange = newDrawAreaRect.width - drawAreaRect.width val heightChange = newDrawAreaRect.height - drawAreaRect.height val panXChange = leftChange + widthChange / 2 val panYChange = topChange + heightChange / 2 val newPanX = pan.x + panXChange val newPanY = pan.y + panYChange if (animate) { resetWithAnimation( pan = Offset(newPanX, newPanY), zoom = newZoom, animationSpec = animationSpec ) } else { snapPanXto(newPanX) snapPanYto(newPanY) snapZoomTo(newZoom) } resetTracking() drawAreaRect = updateImageDrawRectFromTransformation() } /** * If new overlay is bigger, when crop type is dynamic, we need to increase zoom at least * size of bigger dimension for image draw area([drawAreaRect]) to cover overlay([overlayRect]) */ private fun calculateNewZoom(oldRect: Rect, newRect: Rect, zoom: Float): Float { if (oldRect.size == Size.Zero || newRect.size == Size.Zero) return zoom val widthChange = (newRect.width / oldRect.width) .coerceAtLeast(1f) val heightChange = (newRect.height / oldRect.height) .coerceAtLeast(1f) return widthChange.coerceAtLeast(heightChange) * zoom } /** * Calculate valid position for image draw rectangle when pointer is up. Overlay rectangle * should fit inside draw image rectangle to have valid bounds when calculation is completed. * * @param rectOverlay rectangle of overlay that is used for cropping * @param rectDrawArea rectangle of image that is being drawn */ private fun calculateValidImageDrawRect(rectOverlay: Rect, rectDrawArea: Rect): Rect { var width = rectDrawArea.width var height = rectDrawArea.height if (width < rectOverlay.width) { width = rectOverlay.width } if (height < rectOverlay.height) { height = rectOverlay.height } var rectImageArea = Rect(offset = rectDrawArea.topLeft, size = Size(width, height)) if (rectImageArea.left > rectOverlay.left) { rectImageArea = rectImageArea.translate(rectOverlay.left - rectImageArea.left, 0f) } if (rectImageArea.right < rectOverlay.right) { rectImageArea = rectImageArea.translate(rectOverlay.right - rectImageArea.right, 0f) } if (rectImageArea.top > rectOverlay.top) { rectImageArea = rectImageArea.translate(0f, rectOverlay.top - rectImageArea.top) } if (rectImageArea.bottom < rectOverlay.bottom) { rectImageArea = rectImageArea.translate(0f, rectOverlay.bottom - rectImageArea.bottom) } return rectImageArea } /** * Create [Rect] to draw overlay based on selected aspect ratio */ internal fun getOverlayFromAspectRatio( containerWidth: Float, containerHeight: Float, drawAreaWidth: Float, aspectRatio: AspectRatio, coefficient: Float ): Rect { if (aspectRatio == AspectRatio.Original) { val imageAspectRatio = imageSize.width.toFloat() / imageSize.height.toFloat() // Maximum width and height overlay rectangle can be measured with val overlayWidthMax = drawAreaWidth.coerceAtMost(containerWidth * coefficient) val overlayHeightMax = (overlayWidthMax / imageAspectRatio).coerceAtMost(containerHeight * coefficient) val offsetX = (containerWidth - overlayWidthMax) / 2f val offsetY = (containerHeight - overlayHeightMax) / 2f return Rect( offset = Offset(offsetX, offsetY), size = Size(overlayWidthMax, overlayHeightMax) ) } val overlayWidthMax = containerWidth * coefficient val overlayHeightMax = containerHeight * coefficient val aspectRatioValue = aspectRatio.value var width = overlayWidthMax var height = overlayWidthMax / aspectRatioValue if (height > overlayHeightMax) { height = overlayHeightMax width = height * aspectRatioValue } val offsetX = (containerWidth - width) / 2f val offsetY = (containerHeight - height) / 2f return Rect(offset = Offset(offsetX, offsetY), size = Size(width, height)) } /** * Get crop rectangle */ private fun getCropRectangle( bitmapWidth: Int, bitmapHeight: Int, drawAreaRect: Rect, overlayRect: Rect ): Rect { if (drawAreaRect == Rect.Zero || overlayRect == Rect.Zero) return Rect( offset = Offset.Zero, Size(bitmapWidth.toFloat(), bitmapHeight.toFloat()) ) // Calculate latest image draw area based on overlay position // This is valid rectangle that contains crop area inside overlay val newRect = calculateValidImageDrawRect(overlayRect, drawAreaRect) val overlayWidth = overlayRect.width val overlayHeight = overlayRect.height val drawAreaWidth = newRect.width val drawAreaHeight = newRect.height val widthRatio = overlayWidth / drawAreaWidth val heightRatio = overlayHeight / drawAreaHeight val diffLeft = overlayRect.left - newRect.left val diffTop = overlayRect.top - newRect.top val croppedBitmapLeft = (diffLeft * (bitmapWidth / drawAreaWidth)) val croppedBitmapTop = (diffTop * (bitmapHeight / drawAreaHeight)) val croppedBitmapWidth = bitmapWidth * widthRatio val croppedBitmapHeight = bitmapHeight * heightRatio return Rect( offset = Offset(croppedBitmapLeft, croppedBitmapTop), size = Size(croppedBitmapWidth, croppedBitmapHeight) ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/state/DynamicCropState.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.state import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed import androidx.compose.ui.unit.IntSize import cn.netdiscovery.monica.ui.controlpanel.cropimage.TouchRegion import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.AspectRatio import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropProperties import kotlinx.coroutines.coroutineScope import kotlin.math.roundToInt /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.state.DynamicCropState * @author: Tony Shen * @date: 2024/5/26 15:26 * @version: V1.0 <描述当前版本功能> */ class DynamicCropState internal constructor( private var handleSize: Float, imageSize: IntSize, containerSize: IntSize, drawAreaSize: IntSize, aspectRatio: AspectRatio, overlayRatio: Float, maxZoom: Float, fling: Boolean, zoomable: Boolean, pannable: Boolean, rotatable: Boolean, limitPan: Boolean, private val fixedAspectRatio: Boolean, private val minDimension: IntSize? ) : CropState( imageSize = imageSize, containerSize = containerSize, drawAreaSize = drawAreaSize, aspectRatio = aspectRatio, overlayRatio = overlayRatio, maxZoom = maxZoom, fling = fling, zoomable = zoomable, pannable = pannable, rotatable = rotatable, limitPan = limitPan ) { /** * Rectangle that covers bounds of Composable. This is a rectangle uses [containerSize] as * size and [Offset.Zero] as top left corner */ private val rectBounds = Rect( offset = Offset.Zero, size = Size(containerSize.width.toFloat(), containerSize.height.toFloat()) ) // This rectangle is needed to set bounds set at first touch position while // moving to constraint current bounds to temp one from first down // When pointer is up private var rectTemp = Rect.Zero // Touch position for edge of the rectangle, used for not jumping to edge of rectangle // when user moves a handle. We set positionActual as position of selected handle // and using this distance as offset to not have a jump from touch position private var distanceToEdgeFromTouch = Offset.Zero private var doubleTapped = false // Check if transform gesture has been invoked // inside overlay but with multiple pointers to zoom private var gestureInvoked = false override suspend fun updateProperties(cropProperties: CropProperties, forceUpdate: Boolean) { handleSize = cropProperties.handleSize super.updateProperties(cropProperties, forceUpdate) } override suspend fun onDown(change: PointerInputChange) { rectTemp = overlayRect.copy() val position = change.position val touchPositionScreenX = position.x val touchPositionScreenY = position.y val touchPositionOnScreen = Offset(touchPositionScreenX, touchPositionScreenY) // Get whether user touched outside, handles of rectangle or inner region or overlay // rectangle. Depending on where is touched we can move or scale overlay touchRegion = getTouchRegion( position = touchPositionOnScreen, rect = overlayRect, threshold = handleSize ) // This is the difference between touch position and edge // This is required for not moving edge of draw rect to touch position on move distanceToEdgeFromTouch = getDistanceToEdgeFromTouch(touchRegion, rectTemp, touchPositionOnScreen) } override suspend fun onMove(changes: List) { if (changes.isEmpty()) { touchRegion = TouchRegion.None return } gestureInvoked = changes.size > 1 && (touchRegion == TouchRegion.Inside) // If overlay is touched and pointer size is one update // or pointer size is bigger than one but touched any handles update if (touchRegion != TouchRegion.None && changes.size == 1 && !gestureInvoked) { val change = changes.first() // Default min dimension is handle size * 2 val doubleHandleSize = handleSize * 2 val defaultMinDimension = IntSize(doubleHandleSize.roundToInt(), doubleHandleSize.roundToInt()) // update overlay rectangle based on where its touched and touch position to corners // This function moves and/or scales overlay rectangle val newRect = updateOverlayRect( distanceToEdgeFromTouch = distanceToEdgeFromTouch, touchRegion = touchRegion, minDimension = minDimension ?: defaultMinDimension, rectTemp = rectTemp, overlayRect = overlayRect, change = change, aspectRatio = getAspectRatio(), fixedAspectRatio = fixedAspectRatio, ) snapOverlayRectTo(newRect) } } private fun getAspectRatio(): Float { return if (aspectRatio == AspectRatio.Original) { imageSize.width / imageSize.height.toFloat() } else { aspectRatio.value } } override suspend fun onUp(change: PointerInputChange) = coroutineScope { if (touchRegion != TouchRegion.None) { val isInContainerBounds = isRectInContainerBounds(overlayRect) if (!isInContainerBounds) { // Calculate new overlay since it's out of Container bounds rectTemp = calculateOverlayRectInBounds(rectBounds, overlayRect) // Animate overlay to new bounds inside container animateOverlayRectTo(rectTemp) } // Update and animate pan, zoom and image draw area after overlay position is updated animateTransformationToOverlayBounds(overlayRect, true) // Update image draw area after animating pan, zoom or rotation is completed drawAreaRect = updateImageDrawRectFromTransformation() touchRegion = TouchRegion.None } gestureInvoked = false } override suspend fun onGesture( centroid: Offset, panChange: Offset, zoomChange: Float, rotationChange: Float, mainPointer: PointerInputChange, changes: List ) { if (touchRegion == TouchRegion.None || gestureInvoked) { doubleTapped = false val newPan = if (gestureInvoked) Offset.Zero else panChange updateTransformState( centroid = centroid, zoomChange = zoomChange, panChange = newPan, rotationChange = rotationChange ) // Update image draw rectangle based on pan, zoom or rotation change drawAreaRect = updateImageDrawRectFromTransformation() // Fling Gesture if (pannable && fling) { if (changes.size == 1) { addPosition(mainPointer.uptimeMillis, mainPointer.position) } } } } override suspend fun onGestureStart() = Unit override suspend fun onGestureEnd(onBoundsCalculated: () -> Unit) { if (touchRegion == TouchRegion.None || gestureInvoked) { // Gesture end might be called after second tap and we don't want to fling // or animate back to valid bounds when doubled tapped if (!doubleTapped) { if (pannable && fling && !gestureInvoked && zoom > 1) { fling { // We get target value on start instead of updating bounds after // gesture has finished drawAreaRect = updateImageDrawRectFromTransformation() onBoundsCalculated() } } else { onBoundsCalculated() } animateTransformationToOverlayBounds(overlayRect, animate = true) } } } override suspend fun onDoubleTap( offset: Offset, zoom: Float, onAnimationEnd: () -> Unit ) { doubleTapped = true if (fling) { resetTracking() } resetWithAnimation(pan = pan, zoom = zoom, rotation = rotation) // We get target value on start instead of updating bounds after // gesture has finished drawAreaRect = updateImageDrawRectFromTransformation() if (!isOverlayInImageDrawBounds()) { // Moves rectangle to bounds inside drawArea Rect while keeping aspect ratio // of current overlay rect animateOverlayRectTo( getOverlayFromAspectRatio( containerSize.width.toFloat(), containerSize.height.toFloat(), drawAreaSize.width.toFloat(), aspectRatio, overlayRatio ) ) animateTransformationToOverlayBounds(overlayRect, false) } onAnimationEnd() } // TODO Change pan when zoom is bigger than 1f and touchRegion is inside overlay rect // private suspend fun moveOverlayToBounds(change: PointerInputChange, newRect: Rect) { // val bounds = drawAreaRect // // val positionChange = change.positionChangeIgnoreConsumed() // // // When zoom is bigger than 100% and dynamic overlay is not at any edge of // // image we can pan in the same direction motion goes towards when touch region // // of rectangle is not one of the handles but region inside // val isPanRequired = touchRegion == TouchRegion.Inside && zoom > 1f // // // Overlay moving right // if (isPanRequired && newRect.right < bounds.right) { // println("Moving right newRect $newRect, bounds: $bounds") // snapOverlayRectTo(newRect.translate(-positionChange.x, 0f)) // snapPanXto(pan.x - positionChange.x * zoom) // // Overlay moving left // } else if (isPanRequired && pan.x < bounds.left && newRect.left <= 0f) { //// snapOverlayRectTo(newRect.translate(-positionChange.x, 0f)) //// snapPanXto(pan.x - positionChange.x * zoom) // } else if (isPanRequired && pan.y < bounds.top && newRect.top <= 0f) { // // Overlay moving top //// snapOverlayRectTo(newRect.translate(0f, -positionChange.y)) //// snapPanYto(pan.y - positionChange.y * zoom) // } else if (isPanRequired && -pan.y < bounds.bottom && newRect.bottom >= containerSize.height) { // // Overlay moving bottom //// snapOverlayRectTo(newRect.translate(0f, -positionChange.y)) //// snapPanYto(pan.y - positionChange.y * zoom) // } else { // snapOverlayRectTo(newRect) // } //// if (touchRegion != TouchRegion.None) { //// change.consume() //// } // } /** * When pointer is up calculate valid position and size overlay can be updated to inside * a virtual rect between `topLeft = (0,0)` to `bottomRight=(containerWidth, containerHeight)` * * [overlayRect] might be shrunk or moved up/down/left/right to container bounds when * it's out of Composable region */ private fun calculateOverlayRectInBounds(rectBounds: Rect, rectCurrent: Rect): Rect { var width = rectCurrent.width var height = rectCurrent.height if (width > rectBounds.width) { width = rectBounds.width } if (height > rectBounds.height) { height = rectBounds.height } var rect = Rect(offset = rectCurrent.topLeft, size = Size(width, height)) if (rect.left < rectBounds.left) { rect = rect.translate(rectBounds.left - rect.left, 0f) } if (rect.top < rectBounds.top) { rect = rect.translate(0f, rectBounds.top - rect.top) } if (rect.right > rectBounds.right) { rect = rect.translate(rectBounds.right - rect.right, 0f) } if (rect.bottom > rectBounds.bottom) { rect = rect.translate(0f, rectBounds.bottom - rect.bottom) } return rect } /** * Update overlay rectangle based on touch gesture */ private fun updateOverlayRect( distanceToEdgeFromTouch: Offset, touchRegion: TouchRegion, minDimension: IntSize, rectTemp: Rect, overlayRect: Rect, change: PointerInputChange, aspectRatio: Float, fixedAspectRatio: Boolean, ): Rect { val position = change.position // Get screen coordinates from touch position inside composable // and add how far it's from corner to not jump edge to user's touch position val screenPositionX = position.x + distanceToEdgeFromTouch.x val screenPositionY = position.y + distanceToEdgeFromTouch.y return when (touchRegion) { // Corners TouchRegion.TopLeft -> { // Set position of top left while moving with top left handle and // limit position to not intersect other handles val left = screenPositionX.coerceAtMost(rectTemp.right - minDimension.width) val top = if (fixedAspectRatio) { // If aspect ratio is fixed we need to calculate top position based on // left position and aspect ratio val width = rectTemp.right - left val height = width / aspectRatio rectTemp.bottom - height } else { screenPositionY.coerceAtMost(rectTemp.bottom - minDimension.height) } Rect( left = left, top = top, right = rectTemp.right, bottom = rectTemp.bottom ) } TouchRegion.BottomLeft -> { // Set position of top left while moving with bottom left handle and // limit position to not intersect other handles val left = screenPositionX.coerceAtMost(rectTemp.right - minDimension.width) val bottom = if (fixedAspectRatio) { // If aspect ratio is fixed we need to calculate bottom position based on // left position and aspect ratio val width = rectTemp.right - left val height = width / aspectRatio rectTemp.top + height } else { screenPositionY.coerceAtLeast(rectTemp.top + minDimension.height) } Rect( left = left, top = rectTemp.top, right = rectTemp.right, bottom = bottom, ) } TouchRegion.TopRight -> { // Set position of top left while moving with top right handle and // limit position to not intersect other handles val right = screenPositionX.coerceAtLeast(rectTemp.left + minDimension.width) val top = if (fixedAspectRatio) { // If aspect ratio is fixed we need to calculate top position based on // right position and aspect ratio val width = right - rectTemp.left val height = width / aspectRatio rectTemp.bottom - height } else { screenPositionY.coerceAtMost(rectTemp.bottom - minDimension.height) } Rect( left = rectTemp.left, top = top, right = right, bottom = rectTemp.bottom, ) } TouchRegion.BottomRight -> { // Set position of top left while moving with bottom right handle and // limit position to not intersect other handles val right = screenPositionX.coerceAtLeast(rectTemp.left + minDimension.width) val bottom = if (fixedAspectRatio) { // If aspect ratio is fixed we need to calculate bottom position based on // right position and aspect ratio val width = right - rectTemp.left val height = width / aspectRatio rectTemp.top + height } else { screenPositionY.coerceAtLeast(rectTemp.top + minDimension.height) } Rect( left = rectTemp.left, top = rectTemp.top, right = right, bottom = bottom ) } TouchRegion.Inside -> { val drag = change.positionChangeIgnoreConsumed() val scaledDragX = drag.x val scaledDragY = drag.y overlayRect.translate(scaledDragX, scaledDragY) } else -> overlayRect } } /** * get [TouchRegion] based on touch position on screen relative to [overlayRect]. */ private fun getTouchRegion( position: Offset, rect: Rect, threshold: Float ): TouchRegion { val closedTouchRange = -threshold / 2..threshold return when { position.x - rect.left in closedTouchRange && position.y - rect.top in closedTouchRange -> TouchRegion.TopLeft rect.right - position.x in closedTouchRange && position.y - rect.top in closedTouchRange -> TouchRegion.TopRight rect.right - position.x in closedTouchRange && rect.bottom - position.y in closedTouchRange -> TouchRegion.BottomRight position.x - rect.left in closedTouchRange && rect.bottom - position.y in closedTouchRange -> TouchRegion.BottomLeft rect.contains(offset = position) -> TouchRegion.Inside else -> TouchRegion.None } } /** * Returns how far user touched to corner or center of sides of the screen. [TouchRegion] * where user exactly has touched is already passed to this function. For instance user * touched top left then this function returns distance to top left from user's position so * we can add an offset to not jump edge to position user touched. */ private fun getDistanceToEdgeFromTouch( touchRegion: TouchRegion, rect: Rect, touchPosition: Offset ) = when (touchRegion) { TouchRegion.TopLeft -> { rect.topLeft - touchPosition } TouchRegion.TopRight -> { rect.topRight - touchPosition } TouchRegion.BottomLeft -> { rect.bottomLeft - touchPosition } TouchRegion.BottomRight -> { rect.bottomRight - touchPosition } else -> { Offset.Zero } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/state/StaticCropState.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.state import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.unit.IntSize import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.AspectRatio import kotlinx.coroutines.coroutineScope /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.state.StaticCropState * @author: Tony Shen * @date: 2024/5/26 18:56 * @version: V1.0 <描述当前版本功能> */ class StaticCropState internal constructor( imageSize: IntSize, containerSize: IntSize, drawAreaSize: IntSize, aspectRatio: AspectRatio, overlayRatio: Float, maxZoom: Float = 5f, fling: Boolean = false, zoomable: Boolean = true, pannable: Boolean = true, rotatable: Boolean = false, limitPan: Boolean = false ) : CropState( imageSize = imageSize, containerSize = containerSize, drawAreaSize = drawAreaSize, aspectRatio = aspectRatio, overlayRatio = overlayRatio, maxZoom = maxZoom, fling = fling, zoomable = zoomable, pannable = pannable, rotatable = rotatable, limitPan = limitPan ) { override suspend fun onDown(change: PointerInputChange) = Unit override suspend fun onMove(changes: List) = Unit override suspend fun onUp(change: PointerInputChange) = Unit private var doubleTapped = false /* Transform gestures */ override suspend fun onGesture( centroid: Offset, panChange: Offset, zoomChange: Float, rotationChange: Float, mainPointer: PointerInputChange, changes: List ) = coroutineScope { doubleTapped = false updateTransformState( centroid = centroid, zoomChange = zoomChange, panChange = panChange, rotationChange = rotationChange ) // Update image draw rectangle based on pan, zoom or rotation change drawAreaRect = updateImageDrawRectFromTransformation() // Fling Gesture if (pannable && fling) { if (changes.size == 1) { addPosition(mainPointer.uptimeMillis, mainPointer.position) } } } override suspend fun onGestureStart() = coroutineScope {} override suspend fun onGestureEnd(onBoundsCalculated: () -> Unit) { // Gesture end might be called after second tap and we don't want to fling // or animate back to valid bounds when doubled tapped if (!doubleTapped) { if (pannable && fling && zoom > 1) { fling { // We get target value on start instead of updating bounds after // gesture has finished drawAreaRect = updateImageDrawRectFromTransformation() onBoundsCalculated() } } else { onBoundsCalculated() } animateTransformationToOverlayBounds(overlayRect, animate = true) } } // Double Tap override suspend fun onDoubleTap( offset: Offset, zoom: Float, onAnimationEnd: () -> Unit ) { doubleTapped = true if (fling) { resetTracking() } resetWithAnimation(pan = pan, zoom = zoom, rotation = rotation) drawAreaRect = updateImageDrawRectFromTransformation() animateTransformationToOverlayBounds(overlayRect, true) onAnimationEnd() } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/state/TransformState.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.state import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.exponentialDecay import androidx.compose.animation.core.tween import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.unit.IntSize import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.state.TransformState * @author: Tony Shen * @date: 2024/5/26 12:09 * @version: V1.0 <描述当前版本功能> */ @Stable open class TransformState( internal val imageSize: IntSize, val containerSize: IntSize, val drawAreaSize: IntSize, initialZoom: Float = 1f, initialRotation: Float = 0f, minZoom: Float = 1f, maxZoom: Float = 10f, internal var zoomable: Boolean = true, internal var pannable: Boolean = true, internal var rotatable: Boolean = true, internal var limitPan: Boolean = false ) { var drawAreaRect: Rect by mutableStateOf( Rect( offset = Offset( x = ((containerSize.width - drawAreaSize.width) / 2).toFloat(), y = ((containerSize.height - drawAreaSize.height) / 2).toFloat() ), size = Size(drawAreaSize.width.toFloat(), drawAreaSize.height.toFloat()) ) ) internal val zoomMin = minZoom.coerceAtLeast(1f) internal var zoomMax = maxZoom.coerceAtLeast(1f) private val zoomInitial = initialZoom.coerceIn(zoomMin, zoomMax) private val rotationInitial = initialRotation % 360 internal val animatablePanX = Animatable(0f) internal val animatablePanY = Animatable(0f) internal val animatableZoom = Animatable(zoomInitial) internal val animatableRotation = Animatable(rotationInitial) private val velocityTracker = VelocityTracker() init { animatableZoom.updateBounds(zoomMin, zoomMax) require(zoomMax >= zoomMin) } val pan: Offset get() = Offset(animatablePanX.value, animatablePanY.value) val zoom: Float get() = animatableZoom.value val rotation: Float get() = animatableRotation.value val isZooming: Boolean get() = animatableZoom.isRunning val isPanning: Boolean get() = animatablePanX.isRunning || animatablePanY.isRunning val isRotating: Boolean get() = animatableRotation.isRunning val isAnimationRunning: Boolean get() = isZooming || isPanning || isRotating internal open fun updateBounds(lowerBound: Offset?, upperBound: Offset?) { animatablePanX.updateBounds(lowerBound?.x, upperBound?.x) animatablePanY.updateBounds(lowerBound?.y, upperBound?.y) } /** * Update centroid, pan, zoom and rotation of this state when transform gestures are * invoked with one or multiple pointers */ internal open suspend fun updateTransformState( centroid: Offset, panChange: Offset, zoomChange: Float, rotationChange: Float = 1f, ) { val newZoom = (this.zoom * zoomChange).coerceIn(zoomMin, zoomMax) snapZoomTo(newZoom) val newRotation = if (rotatable) { this.rotation + rotationChange } else { 0f } snapRotationTo(newRotation) if (pannable) { val newPan = this.pan + panChange.times(this.zoom) snapPanXto(newPan.x) snapPanYto(newPan.y) } } /** * Reset [pan], [zoom] and [rotation] with animation. */ internal suspend fun resetWithAnimation( pan: Offset = Offset.Zero, zoom: Float = 1f, rotation: Float = 0f, animationSpec: AnimationSpec = tween(400) ) = coroutineScope { launch { animatePanXto(pan.x, animationSpec) } launch { animatePanYto(pan.y, animationSpec) } launch { animateZoomTo(zoom, animationSpec) } launch { animateRotationTo(rotation, animationSpec) } } internal suspend fun animatePanXto( panX: Float, animationSpec: AnimationSpec = tween(400) ) { if (pannable && pan.x != panX) { animatablePanX.animateTo(panX, animationSpec) } } internal suspend fun animatePanYto( panY: Float, animationSpec: AnimationSpec = tween(400) ) { if (pannable && pan.y != panY) { animatablePanY.animateTo(panY, animationSpec) } } internal suspend fun animateZoomTo( zoom: Float, animationSpec: AnimationSpec = tween(400) ) { if (zoomable && this.zoom != zoom) { val newZoom = zoom.coerceIn(zoomMin, zoomMax) animatableZoom.animateTo(newZoom, animationSpec) } } internal suspend fun animateRotationTo( rotation: Float, animationSpec: AnimationSpec = tween(400) ) { if (rotatable && this.rotation != rotation) { animatableRotation.animateTo(rotation, animationSpec) } } internal suspend fun snapPanXto(panX: Float) { if (pannable) { animatablePanX.snapTo(panX) } } internal suspend fun snapPanYto(panY: Float) { if (pannable) { animatablePanY.snapTo(panY) } } internal suspend fun snapZoomTo(zoom: Float) { if (zoomable) { animatableZoom.snapTo(zoom.coerceIn(zoomMin, zoomMax)) } } internal suspend fun snapRotationTo(rotation: Float) { if (rotatable) { animatableRotation.snapTo(rotation) } } /* Fling gesture */ internal fun addPosition(timeMillis: Long, position: Offset) { velocityTracker.addPosition( timeMillis = timeMillis, position = position ) } /** * Create a fling gesture when user removes finger from scree to have continuous movement * until [velocityTracker] speed reached to lower bound */ internal suspend fun fling(onFlingStart: () -> Unit) = coroutineScope { val velocityTracker = velocityTracker.calculateVelocity() val velocity = Offset(velocityTracker.x, velocityTracker.y) var flingStarted = false launch { animatablePanX.animateDecay( velocity.x, exponentialDecay(absVelocityThreshold = 20f), block = { // This callback returns target value of fling gesture initially if (!flingStarted) { onFlingStart() flingStarted = true } } ) } launch { animatablePanY.animateDecay( velocity.y, exponentialDecay(absVelocityThreshold = 20f), block = { // This callback returns target value of fling gesture initially if (!flingStarted) { onFlingStart() flingStarted = true } } ) } } internal fun resetTracking() { velocityTracker.resetTracking() } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/utils/DrawScopeUtils.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.utils import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.DrawScopeUtils * @author: Tony Shen * @date: 2024/5/26 15:43 * @version: V1.0 <描述当前版本功能> */ fun DrawScope.drawGrid(rect: Rect, strokeWidth: Float, color: Color) { val width = rect.width val height = rect.height val gridWidth = width / 3 val gridHeight = height / 3 // Horizontal lines for (i in 1..2) { drawLine( color = color, start = Offset(rect.left, rect.top + i * gridHeight), end = Offset(rect.right, rect.top + i * gridHeight), strokeWidth = strokeWidth ) } // Vertical lines for (i in 1..2) { drawLine( color, start = Offset(rect.left + i * gridWidth, rect.top), end = Offset(rect.left + i * gridWidth, rect.bottom), strokeWidth = strokeWidth ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/utils/ShapeUtils.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.utils import androidx.compose.foundation.shape.GenericShape import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import cn.netdiscovery.monica.ui.controlpanel.cropimage.model.AspectRatio import org.jetbrains.skia.Matrix33 import kotlin.math.cos import kotlin.math.sin import kotlin.math.tan /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.ShapeUtils * @author: Tony Shen * @date: 2024/5/26 12:12 * @version: V1.0 <描述当前版本功能> */ fun createPolygonPath(cx: Float, cy: Float, sides: Int, radius: Float): Path { val angle = 2.0 * Math.PI / sides return Path().apply { moveTo( cx + (radius * cos(0.0)).toFloat(), cy + (radius * sin(0.0)).toFloat() ) for (i in 1 until sides) { lineTo( cx + (radius * cos(angle * i)).toFloat(), cy + (radius * sin(angle * i)).toFloat() ) } close() } } fun createPolygonShape(sides: Int, degrees: Float = 0f): GenericShape { return GenericShape { size: Size, _: LayoutDirection -> val radius = size.width.coerceAtMost(size.height) / 2 addPath( createPolygonPath( cx = size.width / 2, cy = size.height / 2, sides = sides, radius = radius ) ) val matrix = Matrix33.makeRotate(degrees, size.width / 2, size.height / 2) this.asSkiaPath().transform(matrix) } } /** * Creates a [Rect] shape with given aspect ratio. */ fun createRectShape(aspectRatio: AspectRatio): GenericShape { return GenericShape { size: Size, _: LayoutDirection -> val value = aspectRatio.value val width = size.width val height = size.height val shapeSize = if (aspectRatio == AspectRatio.Original) Size(width, height) else if (value > 1) Size(width = width, height = width / value) else Size(width = height * value, height = height) addRect(Rect(offset = Offset.Zero, size = shapeSize)) } } fun Path.scaleAndTranslatePath( width: Float, height: Float, ) { val pathSize = getBounds().size val matrix = Matrix33.makeScale( width / pathSize.width, height / pathSize.height ) this.asSkiaPath().transform(matrix) val left = getBounds().left val top = getBounds().top translate(Offset(-left, -top)) } /** * Build an outline from a shape using aspect ratio, shape and coefficient to scale * * @return [Triple] that contains left, top offset and [Outline] */ fun buildOutline( aspectRatio: AspectRatio, coefficient: Float, shape: Shape, size: Size, layoutDirection: LayoutDirection, density: Density ): Pair { val (shapeSize, offset) = calculateSizeAndOffsetFromAspectRatio(aspectRatio, coefficient, size) val outline = shape.createOutline( size = shapeSize, layoutDirection = layoutDirection, density = density ) return Pair(offset, outline) } /** * Calculate new size and offset based on [size], [coefficient] and [aspectRatio] * * For 4/3f aspect ratio with 1000px width, 1000px height with coefficient 1f * it returns Size(1000f, 750f), Offset(0f, 125f). */ fun calculateSizeAndOffsetFromAspectRatio( aspectRatio: AspectRatio, coefficient: Float, size: Size, ): Pair { val width = size.width val height = size.height val value = aspectRatio.value val newSize = if (aspectRatio == AspectRatio.Original) { Size(width * coefficient, height * coefficient) } else if (value > 1) { Size( width = coefficient * width, height = coefficient * width / value ) } else { Size(width = coefficient * height * value, height = coefficient * height) } val left = (width - newSize.width) / 2 val top = (height - newSize.height) / 2 return Pair(newSize, Offset(left, top)) } class Parallelogram(private val angle: Float) : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { return Outline.Generic( Path().apply { val radian = (90 - angle) * Math.PI / 180 val xOnOpposite = (size.height * tan(radian)).toFloat() moveTo(0f, size.height) lineTo(x = xOnOpposite, y = 0f) lineTo(x = size.width, y = 0f) lineTo(x = size.width - xOnOpposite, y = size.height) lineTo(x = xOnOpposite, y = size.height) } ) } } class Diamond : Shape { /** * Creates the [Outline] for the diamond shape. * * @param size The [Size] of the diamond. * @param layoutDirection The [LayoutDirection] of the diamond. * @param density The [Density] of the diamond. * @return The [Outline] representing the diamond shape. */ override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { return Outline.Generic( Path().apply { val centerX = size.width / 2f val diamondCurve = 60f val width = size.width val height = size.height moveTo(x = 0f + diamondCurve, y = 0f) lineTo(x = width - diamondCurve, y = 0f) lineTo(x = width, y = diamondCurve) lineTo(x = centerX, y = height) lineTo(x = 0f, y = diamondCurve) close() } ) } } class Ticket : Shape { /** * Creates the [Outline] for the ticket shape with rounded corners. * * @param size The [Size] of the shape. * @param layoutDirection The [LayoutDirection] of the shape. * @param density The [Density] of the shape. * @return The [Outline] representing the ticket shape with rounded corners. */ override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { return Outline.Generic( Path().apply { val cornerRadius = 70f // Top left arc arcTo( rect = Rect( left = -cornerRadius, top = -cornerRadius, right = cornerRadius, bottom = cornerRadius ), startAngleDegrees = 90.0f, sweepAngleDegrees = -90.0f, forceMoveTo = false ) lineTo(x = size.width - cornerRadius, y = 0f) // Top right arc arcTo( rect = Rect( left = size.width - cornerRadius, top = -cornerRadius, right = size.width + cornerRadius, bottom = cornerRadius ), startAngleDegrees = 180.0f, sweepAngleDegrees = -90.0f, forceMoveTo = false ) lineTo(x = size.width, y = size.height - cornerRadius) // Bottom right arc arcTo( rect = Rect( left = size.width - cornerRadius, top = size.height - cornerRadius, right = size.width + cornerRadius, bottom = size.height + cornerRadius ), startAngleDegrees = 270.0f, sweepAngleDegrees = -90.0f, forceMoveTo = false ) lineTo(x = cornerRadius, y = size.height) // Bottom left arc arcTo( rect = Rect( left = -cornerRadius, top = size.height - cornerRadius, right = cornerRadius, bottom = size.height + cornerRadius ), startAngleDegrees = 0.0f, sweepAngleDegrees = -90.0f, forceMoveTo = false ) lineTo(x = 0f, y = cornerRadius) close() } ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/cropimage/utils/ZoomUtils.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.cropimage.utils import androidx.compose.ui.graphics.GraphicsLayerScope import cn.netdiscovery.monica.ui.controlpanel.cropimage.state.TransformState /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.cropimage.utils.ZoomUtils * @author: Tony Shen * @date: 2024/5/26 15:32 * @version: V1.0 <描述当前版本功能> */ enum class ZoomLevel { Min, Mid, Max } internal fun getNextZoomLevel(zoomLevel: ZoomLevel): ZoomLevel = when (zoomLevel) { ZoomLevel.Mid -> ZoomLevel.Max ZoomLevel.Max -> ZoomLevel.Min else -> ZoomLevel.Mid } /** * Update graphic layer with [transformState] */ internal fun GraphicsLayerScope.update(transformState: TransformState) { // Set zoom val zoom = transformState.zoom this.scaleX = zoom this.scaleY = zoom // Set pan val pan = transformState.pan val translationX = pan.x val translationY = pan.y this.translationX = translationX this.translationY = translationY // Set rotation this.rotationZ = transformState.rotation } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/doodle/DoodleView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.doodle import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.doodle.model.PathProperties import cn.netdiscovery.monica.ui.widget.color.ColorSelectionDialog import cn.netdiscovery.monica.ui.controlpanel.doodle.widget.PropertiesMenuDialog import cn.netdiscovery.monica.ui.widget.image.gesture.MotionEvent import cn.netdiscovery.monica.ui.widget.image.gesture.dragMotionEvent import cn.netdiscovery.monica.ui.widget.rightSideMenuBar import cn.netdiscovery.monica.ui.widget.toolTipButton import cn.netdiscovery.monica.ui.widget.image.ImageSizeCalculator import cn.netdiscovery.monica.i18n.getCurrentStringResource import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.showimage.DoodleView * @author: Tony Shen * @date: 2024/5/19 21:11 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) @Composable fun drawImage( state: ApplicationState ) { val viewModel: DoodleViewModel = koinInject() val density = LocalDensity.current val i18nState = getCurrentStringResource() // 双路径系统:displayPaths用于显示,originalPaths用于保存 val displayPaths = remember { mutableStateListOf>() } val originalPaths = remember { mutableStateListOf>() } val pathsUndone = remember { mutableStateListOf, Pair>>() } // 分离当前绘制状态,避免与已完成路径的相互影响 val currentDrawingPath = remember { mutableStateOf?>(null) } // 撤销历史限制 val maxUndoHistory = 50 var motionEvent by remember { mutableStateOf(MotionEvent.Idle) } // This is our motion event we get from touch motion var currentPosition by remember { mutableStateOf(Offset.Unspecified) } // This is previous motion event before next touch is saved into this current position var previousPosition by remember { mutableStateOf(Offset.Unspecified) } var currentDisplayPath by remember { mutableStateOf(Path()) } var currentOriginalPath by remember { mutableStateOf(Path()) } var currentPathProperty by remember { mutableStateOf(PathProperties()) } var showColorDialog by remember { mutableStateOf(false) } var showPropertiesDialog by remember { mutableStateOf(false) } // 使用更直接的状态管理 val drawingState = remember { mutableStateOf(Triple(MotionEvent.Idle, Offset.Unspecified, Path())) } // 安全获取图片,避免空指针异常 val image = state.currentImage?.toComposeImageBitmap() // 如果图片为空,显示提示信息 if (image == null) { Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = "请先加载图片", color = Color.Gray ) } return } Box( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .background( brush = Brush.verticalGradient( colors = listOf( MaterialTheme.colors.background, MaterialTheme.colors.surface ) ) ), contentAlignment = Alignment.Center ) { // 使用统一的图片尺寸计算 val (width, height) = ImageSizeCalculator.calculateImageSize(state) // 获取原始图片尺寸和显示尺寸,用于保存时的坐标转换 val originalSize = ImageSizeCalculator.getImagePixelSize(state) val displaySize = ImageSizeCalculator.getImageDisplayPixelSize(state, density.density) // 预计算缩放比例,避免重复计算 val scaleX = if (originalSize != null && displaySize != null) { originalSize.first.toFloat() / displaySize.first.toFloat() } else 1f val scaleY = if (originalSize != null && displaySize != null) { originalSize.second.toFloat() / displaySize.second.toFloat() } else 1f Column( modifier = Modifier.align(Alignment.Center).width(width).height(height), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { val drawModifier = Modifier .padding(8.dp) .shadow(1.dp) .fillMaxWidth() .weight(1f) .background(Color.White) .dragMotionEvent( onDragStart = { pointerInputChange -> motionEvent = MotionEvent.Down currentPosition = pointerInputChange.position // 显示路径使用显示坐标(用于实时显示) currentDisplayPath.moveTo(currentPosition.x, currentPosition.y) // 原始路径使用原始坐标(用于保存) val originalPosition = Offset(currentPosition.x * scaleX, currentPosition.y * scaleY) currentOriginalPath.moveTo(originalPosition.x, originalPosition.y) // 更新分离的绘制状态 currentDrawingPath.value = Pair(currentDisplayPath, currentPathProperty) previousPosition = currentPosition pointerInputChange.consume() }, onDrag = { pointerInputChange -> val newPosition = pointerInputChange.position // 立即更新状态,确保实时响应 motionEvent = MotionEvent.Move currentPosition = newPosition if (previousPosition != Offset.Unspecified) { // 使用quadraticBezierTo绘制平滑曲线 val midX = (previousPosition.x + currentPosition.x) / 2 val midY = (previousPosition.y + currentPosition.y) / 2 // 显示路径使用显示坐标(用于实时显示) currentDisplayPath.quadraticBezierTo(previousPosition.x, previousPosition.y, midX, midY) // 原始路径使用原始坐标(用于保存) val originalPosition = Offset(currentPosition.x * scaleX, currentPosition.y * scaleY) val originalPreviousPosition = Offset(previousPosition.x * scaleX, previousPosition.y * scaleY) val originalMidX = (originalPreviousPosition.x + originalPosition.x) / 2 val originalMidY = (originalPreviousPosition.y + originalPosition.y) / 2 currentOriginalPath.quadraticBezierTo(originalPreviousPosition.x, originalPreviousPosition.y, originalMidX, originalMidY) previousPosition = currentPosition // 更新分离的绘制状态 - 创建新的Path对象确保实时更新 val newDisplayPath = Path().apply { addPath(currentDisplayPath) } currentDrawingPath.value = Pair(newDisplayPath, currentPathProperty) } pointerInputChange.consume() }, onDragEnd = { pointerInputChange -> motionEvent = MotionEvent.Up // 显示路径使用显示坐标(用于实时显示) currentDisplayPath.lineTo(currentPosition.x, currentPosition.y) // 原始路径使用原始坐标(用于保存) val originalPosition = Offset(currentPosition.x * scaleX, currentPosition.y * scaleY) currentOriginalPath.lineTo(originalPosition.x, originalPosition.y) // 同时保存显示路径和原始路径 // 创建PathProperties的副本,避免引用共享 val pathPropertyCopy = PathProperties( strokeWidth = currentPathProperty.strokeWidth, color = Color(currentPathProperty.color.red, currentPathProperty.color.green, currentPathProperty.color.blue, currentPathProperty.color.alpha), alpha = currentPathProperty.alpha, strokeCap = currentPathProperty.strokeCap, strokeJoin = currentPathProperty.strokeJoin ) displayPaths.add(Pair(currentDisplayPath, pathPropertyCopy)) originalPaths.add(Pair(currentOriginalPath, pathPropertyCopy)) logger.info("路径已添加,当前路径数量: displayPaths=${displayPaths.size}, originalPaths=${originalPaths.size}") logger.info("保存的路径颜色: ${pathPropertyCopy.color}") // 清空当前绘制状态 currentDrawingPath.value = null // 重置路径 currentDisplayPath = Path() currentOriginalPath = Path() // 保持当前的颜色设置,不重置currentPathProperty // 限制撤销历史数量,防止内存溢出 if (pathsUndone.size >= maxUndoHistory) { pathsUndone.removeAt(0) } // 注意:不要清空撤销历史,让用户可以撤销之前的操作 currentPosition = Offset.Unspecified previousPosition = currentPosition motionEvent = MotionEvent.Idle pointerInputChange.consume() } ) Canvas(modifier = drawModifier) { this.drawImage(image = image, dstSize = IntSize(width.toPx().toInt(), height.toPx().toInt())) // 绘制已完成的路径(使用显示路径) // 使用key来确保路径变化时能正确重绘 displayPaths.forEachIndexed { index, pathPair -> val path = pathPair.first val property = pathPair.second drawPath( color = property.color, path = path, style = Stroke( width = property.strokeWidth, cap = property.strokeCap, join = property.strokeJoin ) ) } // 绘制当前正在绘制的路径(使用分离的状态) currentDrawingPath.value?.let { (currentPath, currentProps) -> drawPath( color = currentProps.color, path = currentPath, style = Stroke( width = currentProps.strokeWidth, cap = currentProps.strokeCap, join = currentProps.strokeJoin ) ) } } } rightSideMenuBar(modifier = Modifier.align(Alignment.CenterEnd)) { // 选择颜色 toolTipButton(text = i18nState.get("select_color"), painter = painterResource("images/doodle/color.png"), onClick = { showColorDialog = true }) // 属性更改 toolTipButton(text = i18nState.get("change_properties"), painter = painterResource("images/doodle/brush.png"), onClick = { showPropertiesDialog = true }) // 上一步 toolTipButton(text = i18nState.get("previous_step"), painter = painterResource("images/doodle/previous_step.png"), onClick = { logger.info("撤销前状态: displayPaths=${displayPaths.size}, originalPaths=${originalPaths.size}") if (displayPaths.isNotEmpty() && originalPaths.isNotEmpty()) { // 确保两个列表大小一致 if (displayPaths.size == originalPaths.size) { val lastDisplayItem = displayPaths.removeLast() val lastOriginalItem = originalPaths.removeLast() pathsUndone.add(Pair(lastDisplayItem, lastOriginalItem)) // 清空当前绘制状态 currentDrawingPath.value = null logger.info("撤销操作:移除了一个路径,当前路径数量: displayPaths=${displayPaths.size}, originalPaths=${originalPaths.size}") } else { logger.warn("路径列表大小不一致: displayPaths=${displayPaths.size}, originalPaths=${originalPaths.size}") } } else { logger.info("没有可撤销的操作: displayPaths=${displayPaths.size}, originalPaths=${originalPaths.size}") } }) // 撤回 toolTipButton(text = i18nState.get("revoke"), painter = painterResource("images/doodle/revoke.png"), onClick = { if (pathsUndone.isNotEmpty()) { val lastUndoPaths = pathsUndone.removeLast() val (displayPath, originalPath) = lastUndoPaths displayPaths.add(displayPath) originalPaths.add(originalPath) // 强制重绘:重置绘制状态 drawingState.value = Triple(MotionEvent.Idle, Offset.Unspecified, Path()) logger.info("重做操作:恢复了一个路径") } else { logger.info("没有可重做的操作") } }) // 清空画布 toolTipButton(text = i18nState.get("clear_canvas"), painter = painterResource("images/doodle/clear.png"), onClick = { // 清空所有路径 displayPaths.clear() originalPaths.clear() pathsUndone.clear() // 重置当前绘制状态 currentDisplayPath = Path() currentOriginalPath = Path() currentPosition = Offset.Unspecified previousPosition = Offset.Unspecified motionEvent = MotionEvent.Idle // 重置绘制状态,强制重绘 drawingState.value = Triple(MotionEvent.Idle, Offset.Unspecified, Path()) logger.info("画布已清空,所有状态已重置") }) // 保存 toolTipButton(text = i18nState.get("save"), painter = painterResource("images/doodle/save.png"), onClick = { viewModel.saveCanvasToBitmap(density, originalPaths, image, state) }) } if (showColorDialog) { ColorSelectionDialog( currentPathProperty.color, onDismiss = { showColorDialog = false }, onNegativeClick = { showColorDialog = false }, onPositiveClick = { color: Color -> showColorDialog = false currentPathProperty = currentPathProperty.copy(color = color) logger.info("颜色已更改: ${color}") // 更新当前绘制路径的颜色 currentDrawingPath.value?.let { (path, props) -> currentDrawingPath.value = Pair(path, props.copy(color = color)) } } ) } if (showPropertiesDialog) { PropertiesMenuDialog( pathOption = currentPathProperty, onDismiss = { showPropertiesDialog = false }, onPropertiesChanged = { updatedProperty -> currentPathProperty = updatedProperty showPropertiesDialog = false // 更新当前绘制路径的属性 currentDrawingPath.value?.let { (path, props) -> currentDrawingPath.value = Pair(path, updatedProperty) } }, title = i18nState.get("brush_settings") ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/doodle/DoodleViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.doodle import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.CanvasDrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.IntSize import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.doodle.model.PathProperties import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.doodle.DoodleViewModel * @author: Tony Shen * @date: 2024/5/25 20:49 * @version: V1.0 <描述当前版本功能> */ class DoodleViewModel { private val logger: Logger = LoggerFactory.getLogger(DoodleViewModel::class.java) fun saveCanvasToBitmap( density: Density, paths: List>, image: ImageBitmap, state: ApplicationState ) { logger.info("开始保存涂鸦到图片,路径数量: ${paths.size}") val bitmapWidth = image.width val bitmapHeight = image.height logger.info("原始图片尺寸: ${bitmapWidth}x${bitmapHeight}") val drawScope = CanvasDrawScope() val size = Size(bitmapWidth.toFloat(), bitmapHeight.toFloat()) val canvas = Canvas(image) drawScope.draw( density = density, layoutDirection = LayoutDirection.Ltr, canvas = canvas, size = size, ) { state.closePreviewWindow() // 先绘制原始图片 drawImage(image = image, dstSize = IntSize(bitmapWidth, bitmapHeight)) // 直接绘制路径,因为现在路径已经是基于原始图片尺寸的 paths.forEach { pathPair -> val path = pathPair.first val property = pathPair.second drawPath( color = property.color, path = path, style = Stroke( width = property.strokeWidth, cap = property.strokeCap, join = property.strokeJoin ) ) } } state.addQueue(state.currentImage!!) state.currentImage = image.toAwtImage() logger.info("涂鸦保存完成") } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/doodle/model/PathProperties.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.doodle.model import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.doodle.model.PathProperties * @author: Tony Shen * @date: 2024/6/14 15:57 * @version: V1.0 <描述当前版本功能> */ data class PathProperties( val strokeWidth: Float = 10f, val color: Color = Color.Black, val alpha: Float = 1f, val strokeCap: StrokeCap = StrokeCap.Round, val strokeJoin: StrokeJoin = StrokeJoin.Round ) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/doodle/widget/PropertiesMenuDialog.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.doodle.widget import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.Card import androidx.compose.material.Slider import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import cn.netdiscovery.monica.ui.controlpanel.doodle.model.PathProperties import cn.netdiscovery.monica.ui.widget.color.Blue400 import cn.netdiscovery.monica.ui.widget.properties.ExposedSelectionMenu import cn.netdiscovery.monica.i18n.getCurrentStringResource /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.doodle.PropertiesMenuDialog * @author: Tony Shen * @date: 2024/6/16 12:38 * @version: V1.0 <描述当前版本功能> */ @Composable fun PropertiesMenuDialog( pathOption: PathProperties, onDismiss: () -> Unit, onPropertiesChanged: (PathProperties) -> Unit = {}, title: String = "Properties" ) { val i18nState = getCurrentStringResource() var strokeWidth by remember { mutableStateOf(pathOption.strokeWidth) } var strokeCap by remember { mutableStateOf(pathOption.strokeCap) } var strokeJoin by remember { mutableStateOf(pathOption.strokeJoin) } val focusRequester = remember { FocusRequester() } Dialog(onDismissRequest = onDismiss) { Card( elevation = 2.dp, shape = RoundedCornerShape(8.dp), modifier = Modifier .padding(vertical = 8.dp) .height(400.dp) ) { Column(modifier = Modifier.padding(8.dp)) { Text( text = title, color = Blue400, fontSize = 18.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 12.dp, top = 12.dp) ) Canvas( modifier = Modifier .padding(horizontal = 24.dp, vertical = 20.dp) .height(40.dp) .fillMaxWidth() ) { val path = Path() path.moveTo(0f, size.height / 2) path.lineTo(size.width, size.height / 2) drawPath( color = pathOption.color, path = path, style = Stroke( width = strokeWidth, cap = strokeCap, join = strokeJoin ) ) } Text( text = i18nState.get("stroke_width") + " ${strokeWidth.toInt()}", fontSize = 16.sp, modifier = Modifier.padding(horizontal = 12.dp) ) Slider( value = strokeWidth, onValueChange = { strokeWidth = it }, valueRange = 1f..100f, onValueChangeFinished = {}, modifier = Modifier.padding(horizontal = 12.dp).focusRequester(focusRequester) ) ExposedSelectionMenu(title = i18nState.get("stroke_cap"), index = when (strokeCap) { StrokeCap.Butt -> 0 StrokeCap.Round -> 1 else -> 2 }, options = listOf("Butt", "Round", "Square"), onSelected = { println("STOKE CAP $it") strokeCap = when (it) { 0 -> StrokeCap.Butt 1 -> StrokeCap.Round else -> StrokeCap.Square } } ) ExposedSelectionMenu(title = i18nState.get("stroke_join"), index = when (strokeJoin) { StrokeJoin.Miter -> 0 StrokeJoin.Round -> 1 else -> 2 }, options = listOf("Miter", "Round", "Bevel"), onSelected = { println("STOKE JOIN $it") strokeJoin = when (it) { 0 -> StrokeJoin.Miter 1 -> StrokeJoin.Round else -> StrokeJoin.Bevel } } ) // 添加确认和取消按钮 Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp) ) { Button( onClick = { println("确认按钮被点击") onPropertiesChanged( PathProperties( strokeWidth = strokeWidth, color = pathOption.color, alpha = pathOption.alpha, strokeCap = strokeCap, strokeJoin = strokeJoin ) ) onDismiss() }, modifier = Modifier.weight(1f) ) { Text(i18nState.get("confirm")) } Button( onClick = { println("取消按钮被点击") onDismiss() }, modifier = Modifier.weight(1f) ) { Text(i18nState.get("cancel")) } } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/FilterView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.filter import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.filter.viewmodel.FilterViewModel import cn.netdiscovery.monica.ui.controlpanel.filter.widget.FilterAdjustmentPanel import cn.netdiscovery.monica.ui.controlpanel.filter.widget.FilterListPanel import cn.netdiscovery.monica.ui.controlpanel.filter.widget.FilterPreviewArea import cn.netdiscovery.monica.ui.controlpanel.filter.widget.FilterTopAppBar import cn.netdiscovery.monica.ui.controlpanel.filter.widget.buildDefaultParamMap import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.ui.widget.* import cn.netdiscovery.monica.utils.chooseImage import cn.netdiscovery.monica.utils.getBufferedImage import filterNames import loadingDisplay import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory import kotlin.collections.HashMap /** * 重构后的滤镜模块 UI * * @author: Tony Shen * @date: 2025/12/07 * @version: V2.0 */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) @Composable fun filter(state: ApplicationState) { val i18nState = rememberI18nState() val viewModel: FilterViewModel = koinInject() // Toast 状态 var showToast by remember { mutableStateOf(false) } var toastMessage by remember { mutableStateOf("") } // 搜索状态 var searchQuery by remember { mutableStateOf("") } // 预览图像状态(用于实时预览) var previewImage by remember { mutableStateOf(null) } var isDirty by remember { mutableStateOf(false) } // 是否有未应用的更改 var paramVersion by remember { mutableStateOf(0) } // 用于强制刷新参数控件状态 var appliedParamSnapshot by remember { mutableStateOf, String>>(emptyMap()) } // 上次 Apply 的参数快照 val selectedIndexState = remember { mutableStateOf(-1) } val paramMap = remember { androidx.compose.runtime.mutableStateMapOf, String>() } // 当前参数(UI 状态源) // 进入滤镜模块前的基线图:用于“清除滤镜”恢复原效果 val baseImageSnapshot = remember { mutableStateOf(null) } // 当前选中滤镜的基线图:用于“同一滤镜内调参不叠加”,但允许滤镜之间叠加 val currentFilterBaseImageSnapshot = remember { mutableStateOf(null) } // 打开滤镜模块时,锁定一次基线(仅首次);后续如用户重新加载图片,会在 onImageClick 中更新 LaunchedEffect(Unit) { if (baseImageSnapshot.value == null) { // 基线要以“进入滤镜模块前的效果”为准,所以优先 currentImage baseImageSnapshot.value = state.currentImage ?: state.rawImage } } // 缩放状态 var zoomLevel by remember { mutableStateOf(1.0f) } PageLifecycle( onInit = { logger.info("FilterView 启动时初始化") }, onDisposeEffect = { logger.info("FilterView 关闭时释放资源") viewModel.clear() } ) Column( modifier = Modifier .fillMaxSize() .background(Color(0xFFF5F5F5)) ) { // Top App Bar FilterTopAppBar( onSave = { state.closePreviewWindow() }, onExport = { // TODO: 实现导出功能 }, i18nState = i18nState ) // Main Content Area Row( modifier = Modifier .fillMaxSize() .weight(1f), horizontalArrangement = Arrangement.spacedBy(0.dp) ) { // Left Sidebar - Filter List FilterListPanel( modifier = Modifier.width(280.dp), searchQuery = searchQuery, onSearchQueryChange = { searchQuery = it }, selectedIndex = selectedIndexState.value, onFilterSelected = { index -> selectedIndexState.value = index isDirty = false previewImage = null // 重置参数为默认值 paramMap.clear() val filterName = filterNames[index] paramMap.putAll(buildDefaultParamMap(filterName)) // 切换滤镜时:默认参数也作为“已应用”的基线(直到用户 Apply) appliedParamSnapshot = HashMap(paramMap) paramVersion++ // 方式1:滤镜之间叠加 —— 新滤镜基于当前画布图像继续算 val base = state.currentImage ?: state.rawImage if (base != null) { currentFilterBaseImageSnapshot.value = base viewModel.applyFilter( state = state, index = index, paramMap = HashMap(paramMap), sourceImage = base, pushHistory = true ) } }, state = state, i18nState = i18nState ) // Center - Image Preview Area FilterPreviewArea( modifier = Modifier.weight(1f), state = state, previewImage = previewImage, zoomLevel = zoomLevel, onZoomChange = { zoomLevel = it }, onImageClick = { if (state.currentImage == null) { chooseImage(state) { file -> state.rawImage = getBufferedImage(file, state) state.currentImage = state.rawImage state.rawImageFile = file baseImageSnapshot.value = state.currentImage currentFilterBaseImageSnapshot.value = state.currentImage previewImage = null isDirty = false } } }, i18nState = i18nState ) // Right Sidebar - Adjustment Panel FilterAdjustmentPanel( modifier = Modifier.width(300.dp), selectedIndex = selectedIndexState.value, state = state, filterBaseImage = currentFilterBaseImageSnapshot.value, viewModel = viewModel, previewImage = previewImage, onPreviewImageChange = { previewImage = it }, isDirty = isDirty, onDirtyChange = { isDirty = it }, paramVersion = paramVersion, onParamVersionChange = { paramVersion = it }, appliedParamSnapshot = appliedParamSnapshot, onAppliedParamSnapshotChange = { appliedParamSnapshot = it }, paramMap = paramMap, onClearFilter = { val base = baseImageSnapshot.value ?: return@FilterAdjustmentPanel val before = state.currentImage ?: base // 回到进入滤镜模块前的效果,并记录一次历史便于撤销 state.currentImage = base state.addQueue(before) // 清理 UI 状态:取消选中滤镜,避免“选中但未应用”的错觉 selectedIndexState.value = -1 paramMap.clear() appliedParamSnapshot = emptyMap() currentFilterBaseImageSnapshot.value = null previewImage = null isDirty = false paramVersion++ }, onShowToast = { message -> toastMessage = message showToast = true }, i18nState = i18nState ) } } // Loading Indicator if (loadingDisplay) { showLoading() } // Toast Message if (showToast) { centerToast( modifier = Modifier, message = toastMessage ) { showToast = false } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/viewmodel/FilterViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.filter.viewmodel import cn.netdiscovery.monica.config.storage.ConfigManager import cn.netdiscovery.monica.config.storage.ConfigType import cn.netdiscovery.monica.rxcache.FilterParam import cn.netdiscovery.monica.rxcache.Param import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.collator import cn.netdiscovery.monica.utils.doFilter import cn.netdiscovery.monica.utils.extensions.launchWithSuspendLoading import cn.netdiscovery.monica.utils.extensions.safelyConvertToInt import cn.netdiscovery.monica.utils.logger import filterNames import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.slf4j.Logger import java.awt.image.BufferedImage import java.util.LinkedHashMap import kotlin.math.max import kotlin.math.min /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.filter.FilterViewModel * @author: Tony Shen * @date: 2024/5/8 12:09 * @version: V1.0 <描述当前版本功能> */ class FilterViewModel { private val logger: Logger = logger() var job: Job? = null var previewJob: Job? = null private data class PreviewCacheKey( val baseImageId: Int, val filterName: String, val paramsHash: Int ) private data class CachedPreview( val image: BufferedImage, val approxBytes: Long ) private val previewCacheLock = Any() private val previewCacheMaxEntries = 80 private val previewCacheMaxBytes = 256L * 1024L * 1024L // 256MB private var previewCacheBytes: Long = 0 private val previewCache: LinkedHashMap = object : LinkedHashMap(64, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { return size > previewCacheMaxEntries } } private fun approxImageBytes(image: BufferedImage): Long { // 估算:每像素 4 bytes,做上限保护防止溢出 val pixels = image.width.toLong() * image.height.toLong() return min(pixels * 4L, Long.MAX_VALUE / 4L) } private fun buildParamsHash(paramMap: Map, String>): Int { // 需要稳定:排序后 hash val entries = paramMap.entries .sortedWith(compareBy({ it.key.first.lowercase() }, { it.key.second }, { it.value })) var acc = 1 for (e in entries) { acc = 31 * acc + e.key.first.hashCode() acc = 31 * acc + e.key.second.hashCode() acc = 31 * acc + e.value.hashCode() } return acc } private fun clampIntParam(key: String, value: Int): Int { // 兜底:某些滤镜会把参数用作 step,必须 >0 return when (key) { "blockSize" -> max(1, value) else -> value } } private fun getCachedPreview(key: PreviewCacheKey): BufferedImage? { synchronized(previewCacheLock) { return previewCache[key]?.image } } private fun putCachedPreview(key: PreviewCacheKey, image: BufferedImage) { val bytes = approxImageBytes(image) synchronized(previewCacheLock) { // 若已有旧值,先扣掉 previewCache.remove(key)?.let { old -> previewCacheBytes -= old.approxBytes } previewCache[key] = CachedPreview(image = image, approxBytes = bytes) previewCacheBytes += bytes // 先按 entry 数做一次淘汰(LinkedHashMap 自带) while (previewCache.size > previewCacheMaxEntries) { val eldestKey = previewCache.entries.firstOrNull()?.key ?: break previewCache.remove(eldestKey)?.let { removed -> previewCacheBytes -= removed.approxBytes } } // 再按内存上限做淘汰 while (previewCacheBytes > previewCacheMaxBytes && previewCache.isNotEmpty()) { val eldestKey = previewCache.entries.firstOrNull()?.key ?: break previewCache.remove(eldestKey)?.let { removed -> previewCacheBytes -= removed.approxBytes } } } } /** * 保存滤镜参数,并调用滤镜效果 */ fun applyFilter( state: ApplicationState, index: Int, paramMap: Map, String>, sourceImage: BufferedImage? = null, pushHistory: Boolean = true ) { job = state.scope.launchWithSuspendLoading { val baseImage = sourceImage ?: state.rawImage ?: state.currentImage if (baseImage == null) return@launchWithSuspendLoading val tempImage = state.currentImage ?: baseImage val filterName = filterNames[index] val list = mutableListOf() paramMap.forEach { (t, u) -> val value = when(t.second) { "Int" -> clampIntParam(t.first, u.safelyConvertToInt() ?: 0) "Float" -> u.toFloat() "Double" -> u.toDouble() else -> u } list.add(Param(t.first, t.second, value)) } // 按照参数名首字母进行排序 list.sortWith { o1, o2 -> collator.compare(o1.key, o2.key); } // 加载滤镜参数配置,更新参数列表并保存 val defaultFilterParam = FilterParam(filterName, null, null, emptyList()) val filterParam = ConfigManager.load(filterName, defaultFilterParam, ConfigType.RX_CACHE) filterParam.params = list ConfigManager.save(filterName, filterParam, ConfigType.RX_CACHE) // 保存滤镜参数 val array:MutableList = list.map { it.value }.toMutableList() logger.info("filterName: $filterName, array: $array") state.currentImage = doFilter( filterName = filterName, array = array, image = baseImage ) if (pushHistory) { state.addQueue(tempImage) } } } /** * 应用滤镜预览(不保存到历史记录,用于实时预览) */ fun applyFilterPreview( state: ApplicationState, index: Int, paramMap: Map, String>, sourceImageOverride: BufferedImage? = null, debounceMs: Long = 0, onSuccess: (BufferedImage) -> Unit, onError: (Throwable) -> Unit ) { // 取消之前的预览任务 previewJob?.cancel() previewJob = state.scope.launch { try { if (debounceMs > 0) { delay(debounceMs) } // 预览基线:优先用外部传入(用于“滤镜叠加但单滤镜内不叠加”),否则用 currentImage val sourceImage = sourceImageOverride ?: state.currentImage ?: state.rawImage if (sourceImage == null) { return@launch } val filterName = filterNames[index] // Preview cache:相同滤镜 + 相同参数 + 相同基线图 -> 命中 val cacheKey = PreviewCacheKey( baseImageId = System.identityHashCode(sourceImage), filterName = filterName, paramsHash = buildParamsHash(paramMap) ) getCachedPreview(cacheKey)?.let { cached -> onSuccess(cached) return@launch } val list = mutableListOf() paramMap.forEach { (t, u) -> val value = when(t.second) { "Int" -> clampIntParam(t.first, u.safelyConvertToInt() ?: 0) "Float" -> u.toFloat() "Double" -> u.toDouble() else -> u } list.add(Param(t.first, t.second, value)) } // 按照参数名首字母进行排序 list.sortWith { o1, o2 -> collator.compare(o1.key, o2.key); } val array: MutableList = list.map { it.value }.toMutableList() // 预览只处理图像,不触碰 state.currentImage,避免 UI 闪烁/并发风险 val previewResult = doFilter( filterName = filterName, array = array, image = sourceImage ) putCachedPreview(cacheKey, previewResult) onSuccess(previewResult) } catch (e: Exception) { logger.error("Preview filter failed", e) onError(e) } } } fun clear() { if (job !=null && !job!!.isCancelled) { job?.cancel() } if (previewJob !=null && !previewJob!!.isCancelled) { previewJob?.cancel() } synchronized(previewCacheLock) { previewCache.clear() previewCacheBytes = 0 } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/widget/FilterAdjustmentPanel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.filter.widget import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.rxcache.Param import cn.netdiscovery.monica.rxcache.getFilterParam import cn.netdiscovery.monica.rxcache.getFilterRemark import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.filter.viewmodel.FilterViewModel import cn.netdiscovery.monica.ui.i18n.I18nState import cn.netdiscovery.monica.utils.collator import cn.netdiscovery.monica.utils.extensions.safelyConvertToInt import filterNames import kotlinx.coroutines.isActive import kotlinx.coroutines.delay import java.awt.image.BufferedImage import java.util.* import kotlin.math.roundToInt /** * 右侧参数调整面板 */ @Composable fun FilterAdjustmentPanel( modifier: Modifier = Modifier, selectedIndex: Int, state: ApplicationState, filterBaseImage: BufferedImage?, viewModel: FilterViewModel, previewImage: BufferedImage?, onPreviewImageChange: (BufferedImage?) -> Unit, isDirty: Boolean, onDirtyChange: (Boolean) -> Unit, paramVersion: Int, onParamVersionChange: (Int) -> Unit, appliedParamSnapshot: Map, String>, onAppliedParamSnapshotChange: (Map, String>) -> Unit, paramMap: MutableMap, String>, onClearFilter: () -> Unit, onShowToast: (String) -> Unit, i18nState: I18nState ) { var expanded by remember(selectedIndex) { mutableStateOf(true) } Surface( modifier = modifier.fillMaxHeight(), color = Color.White, elevation = 1.dp ) { Column( modifier = Modifier.fillMaxSize() ) { // 面板标题 Surface( modifier = Modifier.fillMaxWidth(), color = Color(0xFFF5F5F5), elevation = 1.dp ) { Text( text = i18nState.getString("adjustments"), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Color(0xFF222222), modifier = Modifier.padding(20.dp) ) } // 可滚动内容区域 Column( modifier = Modifier .fillMaxWidth() .weight(1f) .verticalScroll(rememberScrollState()) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { if (selectedIndex >= 0) { val filterName = filterNames[selectedIndex] // 滤镜名称(可折叠区域) FilterNameSection( filterName = filterName, expanded = expanded, onExpandedChange = { expanded = it } ) // 收起时:展示参数摘要 + Reset 提示,让用户一眼理解当前状态 if (!expanded) { FilterParamSummarySection( filterName = filterName, paramMap = paramMap, i18nState = i18nState ) } AnimatedVisibility(visible = expanded) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { // 参数滑块 FilterParamsSection( filterName = filterName, selectedIndex = selectedIndex, state = state, filterBaseImage = filterBaseImage, viewModel = viewModel, onPreviewImageChange = onPreviewImageChange, onDirtyChange = onDirtyChange, paramVersion = paramVersion, paramMap = paramMap, onCommitted = { latest -> onAppliedParamSnapshotChange(latest) }, i18nState = i18nState ) // Notes 区域 FilterNotesSection( filterName = filterName, i18nState = i18nState ) } } } else { Text( text = i18nState.getString("select_filter_first"), fontSize = 14.sp, color = Color(0xFF666666), modifier = Modifier.padding(vertical = 20.dp) ) } } // 底部按钮区域 if (selectedIndex >= 0) { Divider(color = Color(0xFFE0E0E0), thickness = 1.dp) FilterActionButtons( modifier = Modifier .fillMaxWidth() .padding(20.dp), state = state, filterBaseImage = filterBaseImage, viewModel = viewModel, selectedIndex = selectedIndex, previewImage = previewImage, onPreviewImageChange = onPreviewImageChange, isDirty = isDirty, onDirtyChange = onDirtyChange, paramVersion = paramVersion, onParamVersionChange = onParamVersionChange, appliedParamSnapshot = appliedParamSnapshot, onAppliedParamSnapshotChange = onAppliedParamSnapshotChange, paramMap = paramMap, onClearFilter = onClearFilter, onShowToast = onShowToast, i18nState = i18nState ) } } } } /** * 滤镜名称区域(可折叠) */ @Composable private fun FilterNameSection( filterName: String, expanded: Boolean, onExpandedChange: (Boolean) -> Unit ) { Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(8.dp), backgroundColor = Color.White ) { Column { Row( modifier = Modifier .fillMaxWidth() .clickable { onExpandedChange(!expanded) } .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = filterName, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color(0xFF222222) ) Icon( imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, contentDescription = null, tint = Color(0xFF666666) ) } } } } @Composable private fun FilterParamSummarySection( filterName: String, paramMap: Map, String>, i18nState: I18nState ) { val defaultMap = remember(filterName) { buildDefaultParamMap(filterName) } val paramByKey = remember(filterName) { getFilterParam(filterName).orEmpty().associateBy { it.key } } val changedEntries = remember(filterName, defaultMap, paramMap) { paramMap.entries .filter { (k, v) -> defaultMap[k] != v } .sortedWith(compareBy({ it.key.first.lowercase() }, { it.key.second })) } Card( modifier = Modifier.fillMaxWidth(), elevation = 1.dp, shape = RoundedCornerShape(8.dp), backgroundColor = Color(0xFFFAFAFA) ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = i18nState.getString("param_summary"), fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF222222) ) if (changedEntries.isEmpty()) { Text( text = i18nState.getString("param_summary_default"), fontSize = 12.sp, color = Color(0xFF666666) ) } else { Text( text = i18nState.getString("param_summary_changed_count").format(changedEntries.size), fontSize = 12.sp, color = Color(0xFF007AFF), fontWeight = FontWeight.Medium ) } } if (changedEntries.isNotEmpty()) { val previewItems = changedEntries.take(3) previewItems.forEach { entry -> val key = entry.key.first val rawValue = entry.value val value = run { val param = paramByKey[key] if (param != null) { val meta = FilterParamMetaRegistry.resolve(filterName = filterName, param = param) val intVal = rawValue.safelyConvertToInt() val option = intVal?.let { v -> meta.enumOptions?.firstOrNull { it.value == v } } if (option != null) { "${i18nState.getString(option.labelKey)} ($rawValue)" } else { rawValue } } else { rawValue } } Text( text = "$key: $value", fontSize = 12.sp, color = Color(0xFF444444), maxLines = 1 ) } if (changedEntries.size > 3) { Text( text = "+${changedEntries.size - 3} ...", fontSize = 12.sp, color = Color(0xFF666666) ) } Text( text = i18nState.getString("param_summary_reset_hint"), fontSize = 12.sp, color = Color(0xFF666666), lineHeight = 16.sp ) } } } } /** * 滤镜参数区域 */ @Composable private fun FilterParamsSection( filterName: String, selectedIndex: Int, state: ApplicationState, filterBaseImage: BufferedImage?, viewModel: FilterViewModel, onPreviewImageChange: (BufferedImage?) -> Unit, onDirtyChange: (Boolean) -> Unit, paramVersion: Int, paramMap: MutableMap, String>, onCommitted: (Map, String>) -> Unit, i18nState: I18nState ) { val params: List? = getFilterParam(filterName) if (params != null && params.isNotEmpty()) { val sortedParams = remember(params) { params.sortedWith { o1, o2 -> collator.compare(o1.key, o2.key) } } sortedParams.forEach { param -> FilterParamSlider( param = param, filterName = filterName, selectedIndex = selectedIndex, state = state, filterBaseImage = filterBaseImage, viewModel = viewModel, onPreviewImageChange = onPreviewImageChange, onDirtyChange = onDirtyChange, paramVersion = paramVersion, paramMap = paramMap, onCommitted = onCommitted, i18nState = i18nState ) } } } /** * 单个参数滑块 */ @Composable private fun FilterParamSlider( param: Param, filterName: String, selectedIndex: Int, state: ApplicationState, filterBaseImage: BufferedImage?, viewModel: FilterViewModel, onPreviewImageChange: (BufferedImage?) -> Unit, onDirtyChange: (Boolean) -> Unit, paramVersion: Int, paramMap: MutableMap, String>, onCommitted: (Map, String>) -> Unit, i18nState: I18nState ) { val paramKey = param.key val paramType = param.type val focusManager = LocalFocusManager.current // 从 filterTempMap 获取当前值,如果没有则使用默认值 val defaultValue = when (paramType) { "Int" -> (param.value.toString().safelyConvertToInt() ?: 0).toString() else -> param.value.toString() } val initialValue = paramMap[Pair(paramKey, paramType)] ?: defaultValue var draftText by remember(filterName, paramKey, paramVersion) { mutableStateOf(initialValue) } var lastValidText by remember(filterName, paramKey, paramVersion) { mutableStateOf(initialValue) } var hasFocus by remember { mutableStateOf(false) } var isDragging by remember(filterName, paramKey, paramVersion) { mutableStateOf(false) } var pendingSamplePreview by remember(filterName, paramKey, paramVersion) { mutableStateOf(false) } // 转换为数值用于滑块 val parsedValueOrNull: Float? = when (paramType) { "Int" -> draftText.safelyConvertToInt()?.toFloat() "Float" -> draftText.toFloatOrNull() "Double" -> draftText.toDoubleOrNull()?.toFloat() else -> null } val lastValidNumeric: Float = when (paramType) { "Int" -> lastValidText.safelyConvertToInt()?.toFloat() ?: 0f "Float" -> lastValidText.toFloatOrNull() ?: 0f "Double" -> lastValidText.toDoubleOrNull()?.toFloat() ?: 0f else -> 0f } val numericValue = parsedValueOrNull ?: lastValidNumeric val meta = remember(filterName, paramKey, paramType) { FilterParamMetaRegistry.resolve(filterName = filterName, param = param) } val minValue = meta.min val maxValue = meta.max val step = meta.step val decimals = meta.decimals val enumOptions = meta.enumOptions fun triggerPreviewNow() { if (state.currentImage != null) { viewModel.applyFilterPreview( state = state, index = selectedIndex, paramMap = HashMap(paramMap), sourceImageOverride = filterBaseImage, debounceMs = 0, onSuccess = { image -> onPreviewImageChange(image) }, onError = { } ) } } fun commitNow() { val base = filterBaseImage ?: state.currentImage ?: state.rawImage if (base == null) return viewModel.applyFilter( state = state, index = selectedIndex, paramMap = HashMap(paramMap), sourceImage = base, pushHistory = true ) onPreviewImageChange(null) onDirtyChange(false) onCommitted(HashMap(paramMap)) } // Slider:拖动中不实时算,但每 300ms 抽样触发一次(仅当这段时间内有变化) LaunchedEffect(filterName, paramKey, paramVersion, isDragging) { if (!isDragging) return@LaunchedEffect while (isActive && isDragging) { delay(300) if (pendingSamplePreview) { pendingSamplePreview = false triggerPreviewNow() } } } Card( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), elevation = 1.dp, shape = RoundedCornerShape(8.dp), backgroundColor = Color.White ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { // 参数名称和数值显示 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = paramKey, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF222222) ) if (paramType == "Int" && !enumOptions.isNullOrEmpty()) { EnumParamDropdown( valueText = draftText, options = enumOptions, onSelect = { newInt -> val newValue = newInt.toString() draftText = newValue lastValidText = newValue paramMap[Pair(paramKey, paramType)] = newValue onDirtyChange(true) commitNow() }, i18nState = i18nState ) } else { // 数字输入框 OutlinedTextField( value = draftText, onValueChange = { newValue -> draftText = newValue // 仅当输入可解析时才更新参数与触发预览;否则等待失焦回退 val ok = when (paramType) { "Int" -> newValue.safelyConvertToInt() != null "Float" -> newValue.toFloatOrNull() != null "Double" -> newValue.toDoubleOrNull() != null else -> true } if (ok) { lastValidText = newValue paramMap[Pair(paramKey, paramType)] = newValue onDirtyChange(true) // 输入过程中仍做预览(防止每个字符都提交) if (state.currentImage != null) { viewModel.applyFilterPreview( state = state, index = selectedIndex, paramMap = HashMap(paramMap), sourceImageOverride = filterBaseImage, debounceMs = 200, onSuccess = { image -> onPreviewImageChange(image) }, onError = { } ) } } }, modifier = Modifier .width(140.dp) .onFocusChanged { hasFocus = it.isFocused }, singleLine = true, keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), keyboardActions = KeyboardActions( onDone = { focusManager.clearFocus() // Done:直接提交 commitNow() } ), colors = TextFieldDefaults.outlinedTextFieldColors( focusedBorderColor = Color(0xFF007AFF), unfocusedBorderColor = Color(0xFFE0E0E0) ) ) } } // 滑块(仅对数值类型显示) if (paramType in listOf("Int", "Float", "Double") && (enumOptions.isNullOrEmpty() || paramType != "Int")) { Slider( value = numericValue.coerceIn(minValue, maxValue), onValueChange = { newValue -> val snapped = if (step > 0f) { (newValue / step).roundToInt() * step } else { newValue } val next = when (paramType) { "Int" -> snapped.toInt().toString() "Float" -> String.format(Locale.US, "%.${decimals}f", snapped) "Double" -> String.format(Locale.US, "%.${decimals}f", snapped) else -> snapped.toString() } draftText = next lastValidText = next paramMap[Pair(paramKey, paramType)] = next onDirtyChange(true) isDragging = true pendingSamplePreview = true }, onValueChangeFinished = { // 松手后:立即算一次,确保最终值立刻出预览 isDragging = false pendingSamplePreview = false // 拖动即提交:松手时提交到编辑器(并生成一次历史节点) commitNow() }, valueRange = minValue..maxValue, colors = SliderDefaults.colors( thumbColor = Color(0xFF007AFF), activeTrackColor = Color(0xFF007AFF) ) ) } // 失焦时如果输入非法,回退到上一次有效值(符合 Spec) LaunchedEffect(hasFocus) { if (!hasFocus) { val ok = when (paramType) { "Int" -> draftText.safelyConvertToInt() != null "Float" -> draftText.toFloatOrNull() != null "Double" -> draftText.toDoubleOrNull() != null else -> true } if (!ok) { draftText = lastValidText } else if (draftText != lastValidText) { // 理论上不会发生(lastValidText 会同步),兜底:失焦提交一次 commitNow() } } } } } } @Composable private fun EnumParamDropdown( valueText: String, options: List, onSelect: (Int) -> Unit, i18nState: I18nState ) { var expanded by remember { mutableStateOf(false) } val currentInt = valueText.safelyConvertToInt() val currentLabel = currentInt?.let { v -> options.firstOrNull { it.value == v }?.let { opt -> i18nState.getString(opt.labelKey) } } ?: valueText Box(modifier = Modifier.width(140.dp)) { OutlinedTextField( value = if (currentInt != null) "$currentLabel ($currentInt)" else currentLabel, onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxWidth(), trailingIcon = { IconButton(onClick = { expanded = true }) { Icon(Icons.Default.ArrowDropDown, contentDescription = null) } }, colors = TextFieldDefaults.outlinedTextFieldColors( focusedBorderColor = Color(0xFF007AFF), unfocusedBorderColor = Color(0xFFE0E0E0) ) ) // 透明点击层:避免 TextField 在 Desktop 上吞掉点击事件导致无法展开 Box( modifier = Modifier .matchParentSize() .clickable { expanded = true } ) DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { options.forEach { opt -> DropdownMenuItem(onClick = { expanded = false onSelect(opt.value) }) { Text("${i18nState.getString(opt.labelKey)} (${opt.value})") } } } } } /** * Notes 区域 */ @Composable private fun FilterNotesSection( filterName: String, i18nState: I18nState ) { val remark = getFilterRemark(filterName) if (!remark.isNullOrEmpty()) { Card( modifier = Modifier.fillMaxWidth(), elevation = 1.dp, shape = RoundedCornerShape(8.dp), backgroundColor = Color(0xFFEEEEEE) ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = i18nState.getString("notes"), fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF222222) ) Text( text = remark, fontSize = 12.sp, color = Color(0xFF222222), lineHeight = 18.sp ) } } } } /** * 底部操作按钮 */ @Composable private fun FilterActionButtons( modifier: Modifier = Modifier, state: ApplicationState, filterBaseImage: BufferedImage?, viewModel: FilterViewModel, selectedIndex: Int, previewImage: BufferedImage?, onPreviewImageChange: (BufferedImage?) -> Unit, isDirty: Boolean, onDirtyChange: (Boolean) -> Unit, paramVersion: Int, onParamVersionChange: (Int) -> Unit, appliedParamSnapshot: Map, String>, onAppliedParamSnapshotChange: (Map, String>) -> Unit, paramMap: MutableMap, String>, onClearFilter: () -> Unit, onShowToast: (String) -> Unit, i18nState: I18nState ) { val hasImage = state.currentImage != null val filterName = remember(selectedIndex) { filterNames[selectedIndex] } val defaultMap = remember(selectedIndex) { buildDefaultParamMap(filterName) } val isAtDefault = remember(defaultMap, paramMap) { paramMap == defaultMap } val canCancel = (isDirty || previewImage != null) val canReset = hasImage && !isAtDefault val canClear = hasImage Row( modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { // 左侧:Reset + 清除滤镜 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedButton( onClick = { paramMap.clear() paramMap.putAll(defaultMap) // 拖动即提交:Reset 也直接提交 val base = filterBaseImage ?: state.currentImage ?: state.rawImage if (base != null) { viewModel.applyFilter( state = state, index = selectedIndex, paramMap = HashMap(paramMap), sourceImage = base, pushHistory = true ) } onDirtyChange(false) onPreviewImageChange(null) onAppliedParamSnapshotChange(HashMap(paramMap)) onParamVersionChange(paramVersion + 1) }, enabled = canReset, modifier = Modifier.height(40.dp), colors = ButtonDefaults.outlinedButtonColors( contentColor = Color(0xFF222222) ) ) { Text( text = i18nState.getString("reset_filter"), fontSize = 14.sp, fontWeight = FontWeight.Medium ) } OutlinedButton( onClick = onClearFilter, enabled = canClear, modifier = Modifier.height(40.dp), colors = ButtonDefaults.outlinedButtonColors( contentColor = Color(0xFF222222) ) ) { Text( text = i18nState.getString("clear_filter"), fontSize = 14.sp, fontWeight = FontWeight.Medium ) } } // 右侧:Cancel + Apply Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { OutlinedButton( onClick = { // Cancel:回到上次提交后的参数(仅用于取消未松手/未提交的预览状态) paramMap.clear() paramMap.putAll(appliedParamSnapshot) onParamVersionChange(paramVersion + 1) onPreviewImageChange(null) onDirtyChange(false) }, enabled = canCancel, modifier = Modifier.height(40.dp), colors = ButtonDefaults.outlinedButtonColors( contentColor = Color(0xFF222222) ) ) { Text( text = i18nState.getString("cancel"), fontSize = 14.sp, fontWeight = FontWeight.Medium ) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/widget/FilterListPanel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.filter.widget import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.toPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.i18n.I18nState import filterMaps import filterNames /** * 左侧滤镜列表面板 */ @Composable fun FilterListPanel( modifier: Modifier = Modifier, searchQuery: String, onSearchQueryChange: (String) -> Unit, selectedIndex: Int, onFilterSelected: (Int) -> Unit, state: ApplicationState, i18nState: I18nState ) { val currentImagePainter = remember(state.currentImage) { state.currentImage?.toPainter() } Surface( modifier = modifier.fillMaxHeight(), color = Color.White, elevation = 1.dp ) { Column( modifier = Modifier.fillMaxSize() ) { // 搜索栏 OutlinedTextField( value = searchQuery, onValueChange = onSearchQueryChange, modifier = Modifier .fillMaxWidth() .padding(12.dp), placeholder = { Text( text = i18nState.getString("search"), fontSize = 14.sp ) }, leadingIcon = { Icon( imageVector = Icons.Default.Search, contentDescription = null, tint = Color(0xFF666666) ) }, singleLine = true, colors = TextFieldDefaults.outlinedTextFieldColors( focusedBorderColor = Color(0xFF007AFF), unfocusedBorderColor = Color(0xFFE0E0E0) ), shape = RoundedCornerShape(8.dp) ) // 滤镜列表 val filteredFilters = remember(searchQuery, filterNames) { if (searchQuery.isBlank()) { filterNames.indices.toList() } else { filterNames.indices.filter { index -> filterNames[index].contains(searchQuery, ignoreCase = true) || (filterMaps[filterNames[index]]?.contains(searchQuery, ignoreCase = true) == true) } } } LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (filteredFilters.isEmpty()) { item { Text( text = i18nState.getString("no_filters_found"), fontSize = 12.sp, color = Color(0xFF999999), modifier = Modifier.padding(vertical = 12.dp) ) } return@LazyColumn } itemsIndexed(items = filteredFilters, key = { _, filterIndex -> filterIndex }) { _, filterIndex -> val isSelected = filterIndex == selectedIndex val filterName = filterNames[filterIndex] FilterListItem( filterName = filterName, isSelected = isSelected, onClick = { onFilterSelected(filterIndex) }, state = state, imagePainter = currentImagePainter, noImageText = i18nState.getString("no_image"), modifier = Modifier.fillMaxWidth() ) } } } } } /** * 单个滤镜列表项 */ @Composable private fun FilterListItem( filterName: String, isSelected: Boolean, onClick: () -> Unit, state: ApplicationState, imagePainter: Painter?, noImageText: String, modifier: Modifier = Modifier ) { Card( modifier = modifier .height(80.dp) .clickable(onClick = onClick), elevation = if (isSelected) 4.dp else 1.dp, shape = RoundedCornerShape(8.dp), backgroundColor = if (isSelected) { Color(0xFFE3F2FD) // 淡蓝背景 } else { Color.White } ) { Row( modifier = Modifier .fillMaxSize() .padding(4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { // 左侧选中指示条 if (isSelected) { Box( modifier = Modifier .width(4.dp) .fillMaxHeight() .background( color = Color(0xFF007AFF), shape = RoundedCornerShape(2.dp) ) ) } else { Spacer(modifier = Modifier.width(4.dp)) } // 缩略图预览(使用当前图像的小缩略图) Box( modifier = Modifier .size(60.dp) .background( color = Color(0xFFF5F5F5), shape = RoundedCornerShape(4.dp) ), contentAlignment = Alignment.Center ) { if (imagePainter != null) { // 这里可以显示应用滤镜后的缩略图,简化版先显示原图 Image( painter = imagePainter, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) } else { Text( text = noImageText, fontSize = 12.sp, color = Color(0xFF999999) ) } } // 滤镜名称 Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center ) { Text( text = filterName, fontSize = 14.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, color = if (isSelected) { Color(0xFF007AFF) } else { Color(0xFF222222) } ) // 滤镜描述(如果有) filterMaps[filterName]?.split("-")?.firstOrNull()?.let { desc -> Text( text = desc, fontSize = 12.sp, color = Color(0xFF666666), maxLines = 1 ) } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/widget/FilterParamDefaults.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.filter.widget import cn.netdiscovery.monica.rxcache.Param import cn.netdiscovery.monica.rxcache.getFilterParam import cn.netdiscovery.monica.utils.extensions.safelyConvertToInt import java.util.Locale import kotlin.math.max /** * 生成某个滤镜的“默认参数”Map,用于 Reset / 初始化 / 判断是否处于默认状态。 * * 注意: * - Float/Double 会按 [FilterParamMetaRegistry] 的 decimals 格式化,避免 UI 显示不一致。 */ fun buildDefaultParamMap(filterName: String): Map, String> { val params: List = getFilterParam(filterName).orEmpty() val result = LinkedHashMap, String>(params.size) params.forEach { param -> val meta = FilterParamMetaRegistry.resolve(filterName = filterName, param = param) val key = Pair(param.key, param.type) val defaultValue = when (param.type) { "Int" -> { val raw = param.value.toString().safelyConvertToInt() ?: 0 // 兜底:像 BlockFilter 的 blockSize 这种会作为 step 的参数,必须 >= meta.min max(meta.min.toInt(), raw).toString() } "Float" -> { val v = param.value.toString().toDoubleOrNull() ?: 0.0 String.format(Locale.US, "%.${meta.decimals}f", v) } "Double" -> { val v = param.value.toString().toDoubleOrNull() ?: 0.0 String.format(Locale.US, "%.${meta.decimals}f", v) } else -> param.value.toString() } result[key] = defaultValue } return result } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/widget/FilterParamMeta.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.filter.widget import cn.netdiscovery.monica.rxcache.Param import kotlin.math.max /** * 滤镜参数的 UI 元信息(范围/步长/格式),用于让 Slider 的体验可配置且一致。 */ data class FilterEnumOption( val value: Int, val labelKey: String ) data class FilterParamMeta( val min: Float, val max: Float, val step: Float, val decimals: Int, val enumOptions: List? = null ) object FilterParamMetaRegistry { private const val DEFAULT_INT_MIN = 0f private const val DEFAULT_INT_MAX = 100f private const val DEFAULT_FLOAT_MIN = 0f private const val DEFAULT_FLOAT_MAX = 10f /** * 参数范围/步长/格式的覆盖表(可维护的“配置”)。 * * 优先级: * 1) filterName + paramKey 精确覆盖(最精细,避免误伤) * 2) paramKey 通用覆盖(兜底) * 3) 按类型默认 * * 说明:目前覆盖表写在 Kotlin 里,后续如需完全配置化,可迁移到 json 并在此加载。 */ private val filterKeyOverrides: Map> = mapOf( "ColorFilter" to mapOf( "style" to FilterParamMeta( min = 0f, max = 11f, step = 1f, decimals = 0, enumOptions = listOf( FilterEnumOption(0, "color_filter_style_0"), FilterEnumOption(1, "color_filter_style_1"), FilterEnumOption(2, "color_filter_style_2"), FilterEnumOption(3, "color_filter_style_3"), FilterEnumOption(4, "color_filter_style_4"), FilterEnumOption(5, "color_filter_style_5"), FilterEnumOption(6, "color_filter_style_6"), FilterEnumOption(7, "color_filter_style_7"), FilterEnumOption(8, "color_filter_style_8"), FilterEnumOption(9, "color_filter_style_9"), FilterEnumOption(10, "color_filter_style_10"), FilterEnumOption(11, "color_filter_style_11") ) ) ), "NatureFilter" to mapOf( "style" to FilterParamMeta( min = 1f, max = 8f, step = 1f, decimals = 0, enumOptions = listOf( FilterEnumOption(1, "nature_filter_style_1"), FilterEnumOption(2, "nature_filter_style_2"), FilterEnumOption(3, "nature_filter_style_3"), FilterEnumOption(4, "nature_filter_style_4"), FilterEnumOption(5, "nature_filter_style_5"), FilterEnumOption(6, "nature_filter_style_6"), FilterEnumOption(7, "nature_filter_style_7"), FilterEnumOption(8, "nature_filter_style_8") ) ) ), "BlockFilter" to mapOf( // BlockFilter:blockSize 会被用作 Kotlin range 的 step,必须 > 0 "blocksize" to FilterParamMeta(min = 1f, max = 128f, step = 1f, decimals = 0) ), "CropFilter" to mapOf( // CropFilter:x, y 为起始坐标,w, h 为裁剪区域宽高 // 设置较大的最大值以支持高分辨率图片裁剪(最大支持 8192 像素) "x" to FilterParamMeta(min = 0f, max = 8192f, step = 1f, decimals = 0), "y" to FilterParamMeta(min = 0f, max = 8192f, step = 1f, decimals = 0), "w" to FilterParamMeta(min = 1f, max = 8192f, step = 1f, decimals = 0), "h" to FilterParamMeta(min = 1f, max = 8192f, step = 1f, decimals = 0) ) ) /** * 基于参数名/类型给出一个“默认但可维护”的范围配置。 * 后续如需更精细(按 filterName+paramKey),可以在这里加覆盖表。 */ fun resolve(filterName: String, param: Param): FilterParamMeta { val paramKey = param.key.lowercase() // 1) filterName + paramKey 精确覆盖 filterKeyOverrides[filterName]?.get(paramKey)?.let { return it } // 类型默认 return when (param.type) { "Int" -> FilterParamMeta( min = DEFAULT_INT_MIN, max = max(DEFAULT_INT_MAX, DEFAULT_INT_MIN + 1f), step = 1f, decimals = 0 ) "Float", "Double" -> FilterParamMeta( min = DEFAULT_FLOAT_MIN, max = max(DEFAULT_FLOAT_MAX, DEFAULT_FLOAT_MIN + 0.01f), step = 0.01f, decimals = 2 ) else -> FilterParamMeta( min = DEFAULT_INT_MIN, max = DEFAULT_INT_MAX, step = 1f, decimals = 0 ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/widget/FilterPreviewArea.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.filter.widget import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.i18n.I18nState import java.awt.image.BufferedImage /** * 中间图像预览区域 */ @Composable fun FilterPreviewArea( modifier: Modifier = Modifier, state: ApplicationState, previewImage: BufferedImage?, zoomLevel: Float, onZoomChange: (Float) -> Unit, onImageClick: () -> Unit, i18nState: I18nState ) { Surface( modifier = modifier.fillMaxHeight(), color = Color(0xFFF5F5F5), elevation = 1.dp ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { // 显示预览图像或当前图像 val displayImage = previewImage ?: state.currentImage if (displayImage != null) { // 图像预览 Card( modifier = Modifier .fillMaxSize() .padding(16.dp), shape = RoundedCornerShape(8.dp), elevation = 2.dp, backgroundColor = Color.White ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Image( painter = displayImage.toPainter(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier .fillMaxSize() .graphicsLayer( scaleX = zoomLevel, scaleY = zoomLevel ) ) } } } else { // 空状态提示 Card( modifier = Modifier .fillMaxSize() .padding(16.dp) .clickable(onClick = onImageClick), shape = RoundedCornerShape(8.dp), elevation = 2.dp, backgroundColor = Color.White ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = i18nState.getString("click_to_select_image"), fontSize = 18.sp, color = Color(0xFF666666), textAlign = TextAlign.Center ) } } } } // 底部缩放控制栏 Surface( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 16.dp), shape = RoundedCornerShape(20.dp), color = Color.White.copy(alpha = 0.9f), elevation = 4.dp ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { // 缩小按钮 TextButton( onClick = { onZoomChange((zoomLevel - 0.1f).coerceAtLeast(0.1f)) }, modifier = Modifier.height(32.dp) ) { Text( text = "−", fontSize = 18.sp, color = Color(0xFF222222), fontWeight = FontWeight.Bold ) } // 缩放百分比显示 Text( text = "${(zoomLevel * 100).toInt()}%", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = Color(0xFF222222), modifier = Modifier.width(50.dp), textAlign = TextAlign.Center ) // 放大按钮 IconButton( onClick = { onZoomChange((zoomLevel + 0.1f).coerceAtMost(5.0f)) }, modifier = Modifier.size(32.dp) ) { Icon( imageVector = Icons.Default.Add, contentDescription = "Zoom In", tint = Color(0xFF222222) ) } // 分隔线 Divider( modifier = Modifier .height(24.dp) .width(1.dp), color = Color(0xFFE0E0E0) ) // 适应屏幕按钮(使用文本代替图标) TextButton( onClick = { onZoomChange(1.0f) }, modifier = Modifier.height(32.dp) ) { Text( text = i18nState.getString("fit"), fontSize = 12.sp, color = Color(0xFF222222) ) } } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/filter/widget/FilterTopAppBar.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.filter.widget import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.ui.i18n.I18nState /** * 滤镜模块顶部应用栏 */ @Composable fun FilterTopAppBar( onSave: () -> Unit, onExport: () -> Unit, i18nState: I18nState ) { Surface( modifier = Modifier .fillMaxWidth() .height(56.dp), elevation = 2.dp, color = Color.White ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { // 左侧标题和菜单 Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = i18nState.getString("image_editor_filter_module"), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Color(0xFF222222) ) } // 右侧按钮 Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { // Save 按钮(次要按钮) Button( onClick = onSave, colors = ButtonDefaults.buttonColors( backgroundColor = Color(0xFFE0E0E0), contentColor = Color(0xFF222222) ), modifier = Modifier.height(36.dp) ) { Text( text = i18nState.getString("save"), fontSize = 14.sp, fontWeight = FontWeight.Medium ) } // Export 按钮(主要按钮) Button( onClick = onExport, colors = ButtonDefaults.buttonColors( backgroundColor = Color(0xFF007AFF), contentColor = Color.White ), modifier = Modifier.height(36.dp) ) { Text( text = i18nState.getString("export"), fontSize = 14.sp, fontWeight = FontWeight.Medium ) } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/generategif/GenerateGifView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.generategif import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.ui.widget.* import cn.netdiscovery.monica.utils.chooseImage import cn.netdiscovery.monica.utils.getBufferedImage import cn.netdiscovery.monica.utils.getValidateField import org.koin.compose.koinInject import java.io.File import loadingDisplay /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.generategif.GenerateGifView * @author: Tony Shen * @date: 2025/2/23 16:16 * @version: V1.0 <描述当前版本功能> */ private var showVerifyToast by mutableStateOf(false) private var verifyToastMessage by mutableStateOf("") private val height = 600.dp // 上传图片的区域 @OptIn(ExperimentalMaterialApi::class) @Composable fun generateGif(state: ApplicationState) { val i18nState = rememberI18nState() val viewModel: GenerateGifViewModel = koinInject() var selectedImages by remember { mutableStateOf>(emptyList()) } var widthText by remember { mutableStateOf("400") } var heightText by remember { mutableStateOf("400") } var frameDelayText by remember { mutableStateOf("500") } var loopEnabled by remember { mutableStateOf(false) } fun clear() { widthText = "400" heightText = "400" frameDelayText = "500" loopEnabled = false selectedImages = emptyList() } @Composable fun addImageCard(state:ApplicationState) { Card(onClick = { chooseImage(state) {imageFile -> selectedImages += imageFile }}, modifier = Modifier.padding(10.dp).width(300.dp).height(150.dp), shape = RoundedCornerShape(8.dp)) { Row(horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { Text(i18nState.getString("add_image_first")) } } } Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { if (selectedImages.isNotEmpty()) { subTitle(modifier = Modifier.padding(start = 20.dp), text = i18nState.getString("select_images"), color = Color.Black) Box(modifier = Modifier.height(height).fillMaxWidth()) { LazyVerticalGrid( columns = GridCells.Fixed(5), modifier = Modifier.fillMaxWidth() ) { val emptyFile = File("") itemsIndexed(selectedImages + emptyFile) { index, imageFile -> if (index < selectedImages.size) { Card(modifier = Modifier.padding(10.dp), shape = RoundedCornerShape(8.dp)) { Column(modifier = Modifier.padding(4.dp), horizontalAlignment = Alignment.CenterHorizontally) { val bitmap = remember(imageFile) { getBufferedImage(imageFile).toComposeImageBitmap() } Image(painter = BitmapPainter(bitmap), contentDescription = imageFile.name, modifier = Modifier.size(100.dp)) Row(horizontalArrangement = Arrangement.SpaceEvenly) { Button(onClick = { selectedImages = selectedImages.toMutableList().apply { removeAt(index) } }) { Text("Delete") } if (index > 0) { Button(onClick = { selectedImages = selectedImages.toMutableList().apply { add(index - 1, removeAt(index)) } }) { Text("Up") } } if (index < selectedImages.size - 1) { Button(onClick = { selectedImages = selectedImages.toMutableList().apply { add(index + 1, removeAt(index)) } }) { Text("Down") } } } } } } else { addImageCard(state) } } } } } else { Spacer(modifier = Modifier.height(16.dp)) Column(modifier = Modifier.height(height).fillMaxWidth()) { addImageCard(state) } } Spacer(modifier = Modifier.height(16.dp)) // gif 生成策略 subTitleWithDivider(text = i18nState.getString("gif_generation_strategy"), color = Color.Black) Row { basicTextFieldWithTitle(titleText = i18nState.getString("gif_width"), widthText, Modifier.padding(end = 20.dp)) { str -> widthText = str } basicTextFieldWithTitle(titleText = i18nState.getString("gif_height"), heightText, Modifier.padding(end = 20.dp)) { str -> heightText = str } } Row(modifier = Modifier.padding(top = 20.dp)) { // 每一帧间隔 (ms) basicTextFieldWithTitle(titleText = i18nState.getString("frame_interval"), frameDelayText) { str -> frameDelayText = str } } Row(modifier = Modifier.padding(top = 16.dp), verticalAlignment = Alignment.CenterVertically) { Text(i18nState.getString("loop_playback")) Checkbox(checked = loopEnabled, onCheckedChange = { loopEnabled = it }) } Spacer(modifier = Modifier.height(40.dp)) Row { confirmButton( enabled = true, text = "返回首页", onClick = { clear() state.closePreviewWindow() }) confirmButton( enabled = true, text = "清空图片", modifier = Modifier.padding(start = 20.dp), onClick = { clear() }) confirmButton( enabled = selectedImages.isNotEmpty(), text = "生成 gif", modifier = Modifier.padding(start = 20.dp), onClick = { val width = getValidateField(block = { widthText.toInt() } , failed = { showGenerateGifVerifyToast("width 需要 int 类型") }) ?: return@confirmButton val height = getValidateField(block = { heightText.toInt() } , failed = { showGenerateGifVerifyToast("height 需要 int 类型") }) ?: return@confirmButton val frameDelay = getValidateField(block = { frameDelayText.toInt() } , failed = { showGenerateGifVerifyToast("frameDelay 需要 int 类型") }) ?: return@confirmButton viewModel.generateGif(state, selectedImages, width, height, frameDelay, loopEnabled) { showGenerateGifVerifyToast("gif 已生成") clear() } }) } } if (loadingDisplay) { showLoading() } if (showVerifyToast) { centerToast(message = verifyToastMessage) { showVerifyToast = false } } } } private fun showGenerateGifVerifyToast(message: String) { verifyToastMessage = message showVerifyToast = true } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/generategif/GenerateGifViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.generategif import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.Action import cn.netdiscovery.monica.utils.currentTime import cn.netdiscovery.monica.utils.extensions.launchWithLoading import com.madgag.gif.fmsware.AnimatedGifEncoder import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File import java.io.FileOutputStream import javax.imageio.ImageIO /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.generategif.GenerateGifViewModel * @author: Tony Shen * @date: 2025/3/4 14:06 * @version: V1.0 <描述当前版本功能> */ class GenerateGifViewModel { private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) fun generateGif(state: ApplicationState,images: List, width: Int, height: Int, frameDelay: Int, loopEnabled: Boolean, block: Action) { logger.info("start to generate gif") state.scope.launchWithLoading { val gifEncoder = AnimatedGifEncoder() gifEncoder.setSize(width, height) gifEncoder.start(FileOutputStream("output_${currentTime()}.gif")) gifEncoder.setDelay(frameDelay) gifEncoder.setRepeat(if (loopEnabled) 0 else 1) // Set loop option images.forEach { imageFile -> val image = ImageIO.read(imageFile) gifEncoder.addFrame(image) } gifEncoder.finish() logger.info("gif generated successfully!") block() } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/CoordinateSystem.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.Density import cn.netdiscovery.monica.utils.logger import org.slf4j.Logger import kotlin.math.pow import kotlin.math.sqrt /** * 坐标系统工具类 * 统一处理坐标转换、边界检查和验证 * * @author Tony Shen * @date 2025/9/1 16:09 * @version V1.0 */ object CoordinateSystem { private val logger: Logger = logger() /** * 坐标验证结果 */ data class ValidationResult( val isValid: Boolean, val message: String = "", val correctedOffset: Offset? = null ) /** * 验证坐标是否有效 */ fun validateOffset(offset: Offset, imageWidth: Int, imageHeight: Int): ValidationResult { return when { offset == Offset.Unspecified -> { ValidationResult(false, "坐标未指定") } offset.x.isNaN() || offset.y.isNaN() -> { ValidationResult(false, "坐标包含NaN值") } offset.x.isInfinite() || offset.y.isInfinite() -> { ValidationResult(false, "坐标包含无穷值") } offset.x < 0 || offset.y < 0 -> { ValidationResult(false, "坐标超出图像边界(负值)") } offset.x > imageWidth || offset.y > imageHeight -> { ValidationResult(false, "坐标超出图像边界") } else -> { ValidationResult(true, "坐标有效") } } } /** * 计算文本位置(考虑文本尺寸和边界) * @param dragOffset 拖拽偏移量(像素,相对于画布/图像显示中心) * @param imageWidth 图像显示宽度(像素) * @param imageHeight 图像显示高度(像素) * @param density 屏幕密度 * @param textFieldWidth 文本输入框宽度(dp) * @param textFieldHeight 文本输入框高度(dp) * @param fontSize 字体大小(用于文本居中对齐) * @return 文本在Canvas中的位置(Canvas坐标,以Canvas左上角为原点,像素) */ fun calculateTextPosition( dragOffset: Offset, imageWidth: Int, imageHeight: Int, density: Density, textFieldWidth: Float = 250f, textFieldHeight: Float = 130f, fontSize: Float = 40f ): Offset { // dragOffset 是文本输入框中心相对于画布中心的偏移(像素) // 画布显示尺寸等于图像显示尺寸(imageWidth x imageHeight) // 画布中心在 (imageWidth/2, imageHeight/2) val canvasCenterX = imageWidth / 2f val canvasCenterY = imageHeight / 2f // 文本中心位置 = 画布中心 + 拖拽偏移 val textCenterX = canvasCenterX + dragOffset.x val textCenterY = canvasCenterY + dragOffset.y // 确保文本中心不会超出图像边界(考虑文本可能的最大宽度和高度) // 使用 fontSize 作为文本高度的近似值,文本宽度需要根据实际文本内容计算 // 这里使用一个保守的估计值 val estimatedTextHeight = fontSize * 1.2f // 字体高度的1.2倍作为安全边距 val textFieldWidthPx = textFieldWidth * density.density val estimatedTextWidth = textFieldWidthPx * 0.8f // 使用输入框宽度的80%作为文本宽度的估计 val clampedX = textCenterX.coerceIn(estimatedTextWidth / 2f, imageWidth - estimatedTextWidth / 2f) val clampedY = textCenterY.coerceIn(estimatedTextHeight / 2f, imageHeight - estimatedTextHeight / 2f) val canvasPosition = Offset(clampedX, clampedY) logger.info("文本位置计算: 拖拽偏移=$dragOffset, Canvas中心=($canvasCenterX, $canvasCenterY), 文本中心=($textCenterX, $textCenterY), 修正位置=($clampedX, $clampedY), 最终Canvas位置=$canvasPosition") return canvasPosition } /** * 检查点是否在图像范围内 */ fun isPointInImage(point: Offset, imageWidth: Int, imageHeight: Int): Boolean { return point.x >= 0 && point.x <= imageWidth && point.y >= 0 && point.y <= imageHeight } /** * 计算两点之间的距离 */ fun calculateDistance(point1: Offset, point2: Offset): Float { return sqrt((point2.x - point1.x).pow(2) + (point2.y - point1.y).pow(2)) } /** * 计算圆的半径 */ fun calculateCircleRadius(center: Offset, pointOnCircle: Offset): Float { return calculateDistance(center, pointOnCircle) } /** * 验证形状的边界 */ fun validateShapeBoundary( points: List, imageWidth: Int, imageHeight: Int ): ValidationResult { if (points.isEmpty()) { return ValidationResult(false, "形状没有顶点") } val invalidPoints = points.filter { !isPointInImage(it, imageWidth, imageHeight) } return if (invalidPoints.isEmpty()) { ValidationResult(true, "形状边界有效") } else { ValidationResult(false, "形状包含超出边界的点: $invalidPoints") } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/EditorController.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.drawscope.CanvasDrawScope import androidx.compose.ui.graphics.toAwtImage import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.ImageLayer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.Layer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.LayerManager import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.LayerRenderer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.LayerTransform import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.LayerType import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.ShapeLayer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.SpecialLayerHelper import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape import java.awt.image.BufferedImage import java.util.UUID import org.slf4j.Logger import org.slf4j.LoggerFactory /** * EditorController 负责协调 LayerManager、LayerRenderer 以及导出逻辑, * 同时记录当前工具与激活图层信息,供 UI 直接调用。 */ class EditorController( val layerManager: LayerManager = LayerManager() ) { private val logger: Logger = LoggerFactory.getLogger(EditorController::class.java) // 使用 SpecialLayerHelper 来管理背景层,集中处理背景层相关逻辑 private val specialLayerHelper = SpecialLayerHelper(layerManager, BACKGROUND_LAYER_NAME) companion object { /** * 限制最多创建的形状层数量 * 方案一(简化设计):限制为1个形状层,保留多个图像层 */ private const val MAX_SHAPE_LAYERS = 1 /** * 背景图层名称常量,统一管理,避免硬编码 */ const val BACKGROUND_LAYER_NAME = "背景图层" } val layerRenderer = LayerRenderer(layerManager) private val _currentTool = mutableStateOf(EditorTool.SELECTION) val currentTool get() = _currentTool.value fun selectTool(tool: EditorTool) { _currentTool.value = tool } fun addLayer(layer: Layer, index: Int? = null) { layerManager.addLayer(layer, index) } fun createImageLayer( name: String, image: ImageBitmap?, index: Int? = null ): ImageLayer { val layer = ImageLayer(name = name, image = image) layerManager.addLayer(layer, index) return layer } /** * 添加形状层,但限制最多只能创建 MAX_SHAPE_LAYERS 个形状层 * 如果已达上限,返回现有的第一个形状层并激活它 */ fun addShapeLayer(name: String = "形状图层"): ShapeLayer? { val existingShapeLayers = layerManager.layers.value.filter { it.type == LayerType.SHAPE } if (existingShapeLayers.size >= MAX_SHAPE_LAYERS) { // 如果已达上限,返回现有的第一个形状层并激活它 val existing = existingShapeLayers.firstOrNull() as? ShapeLayer existing?.let { layerManager.setActiveLayer(it.id) } return existing } val layer = ShapeLayer(name) layerManager.addLayer(layer) return layer } /** * 获取当前形状层数量 */ fun getShapeLayerCount(): Int { return layerManager.layers.value.count { it.type == LayerType.SHAPE } } /** * 检查是否可以添加更多形状层 */ fun canAddShapeLayer(): Boolean { return getShapeLayerCount() < MAX_SHAPE_LAYERS } fun removeLayer(id: UUID) { if (isBackgroundLayer(id)) { // 背景层不应该被删除,记录警告但不执行删除 logger.warn("尝试删除背景层,操作被阻止") return } layerManager.removeLayer(id) } fun clearLayers() { layerManager.clear() } fun setActiveLayer(id: UUID?) { layerManager.setActiveLayer(id) } fun ensureActiveShapeLayer(): ShapeLayer { val active = layerManager.activeLayer.value if (active is ShapeLayer) return active val existing = layerManager.layers.value.firstOrNull { it.type == LayerType.SHAPE } as? ShapeLayer if (existing != null) { layerManager.setActiveLayer(existing.id) return existing } val newLayer = ShapeLayer("Shape Layer") layerManager.addLayer(newLayer) return newLayer } /** * 检查是否可以在当前激活的形状层上绘制 * 返回 true 表示可以绘制,false 表示图层锁定或不是形状层 * 注意:此方法会先确保存在一个激活的形状层,然后再检查锁定状态 */ fun canDrawOnActiveShapeLayer(): Boolean { // 先确保有一个激活的形状层 val shapeLayer = try { ensureActiveShapeLayer() } catch (e: Exception) { return false } // 检查是否锁定 return !shapeLayer.locked } fun addShapeToActiveLayer(key: Offset, displayShape: Shape, originalShape: Shape) { val shapeLayer = ensureActiveShapeLayer() // 如果形状层已锁定,不允许添加形状 if (shapeLayer.locked) { return } shapeLayer.addShape(key, displayShape, originalShape) } fun replaceShapesInActiveLayer( displayLines: SnapshotStateMap, originalLines: SnapshotStateMap, displayCircles: SnapshotStateMap, originalCircles: SnapshotStateMap, displayTriangles: SnapshotStateMap, originalTriangles: SnapshotStateMap, displayRectangles: SnapshotStateMap, originalRectangles: SnapshotStateMap, displayPolygons: SnapshotStateMap, originalPolygons: SnapshotStateMap, displayTexts: SnapshotStateMap, originalTexts: SnapshotStateMap ) { val shapeLayer = ensureActiveShapeLayer() // 如果形状层已锁定,不允许替换形状 if (shapeLayer.locked) { return } shapeLayer.replaceAll( displayLines, originalLines, displayCircles, originalCircles, displayTriangles, originalTriangles, displayRectangles, originalRectangles, displayPolygons, originalPolygons, displayTexts, originalTexts ) } /** * 将当前所有图层合成为 [androidx.compose.ui.graphics.ImageBitmap]。 * * @param width 导出宽度(像素) * @param height 导出高度(像素) * @param density 当前绘制使用的密度 * @param backgroundColor 可选背景色,默认为透明 * @param layers 指定要导出的图层集合,默认为 LayerManager 当前图层快照 */ fun exportImageBitmap( width: Int, height: Int, density: Density, backgroundColor: Color = Color.Transparent, layers: List = layerManager.layers.value ): ImageBitmap { val bitmap = ImageBitmap(width, height) val canvas = Canvas(bitmap) val drawScope = CanvasDrawScope() val size = Size(width.toFloat(), height.toFloat()) drawScope.draw( density = density, layoutDirection = LayoutDirection.Ltr, canvas = canvas, size = size ) { if (backgroundColor.alpha > 0f) { val rect = Rect(Offset.Zero, size) val paint = Paint().apply { color = backgroundColor } drawContext.canvas.drawRect(rect, paint) } layerRenderer.drawAll(this, layers) } return bitmap } /** * 将当前所有图层合成为 [java.awt.image.BufferedImage]。 * * @param width 导出宽度(像素) * @param height 导出高度(像素) * @param density 当前绘制使用的密度 * @param backgroundColor 可选背景色,默认为透明 * @param layers 指定要导出的图层集合,默认为 LayerManager 当前图层快照 */ fun exportBufferedImage( width: Int, height: Int, density: Density, backgroundColor: Color = Color.Transparent, layers: List = layerManager.layers.value ): BufferedImage { val bitmap = exportImageBitmap(width, height, density, backgroundColor, layers) return bitmap.toAwtImage() } /** * 更新图像层的位置(拖动) */ /** * 更新图像层的位置 * @param layerId 图层ID * @param canvasPosition 画布坐标位置(用户拖动的目标位置) * @param canvasWidth 画布宽度 * @param canvasHeight 画布高度 */ fun updateImageLayerPosition(layerId: UUID, canvasPosition: Offset, canvasWidth: Float, canvasHeight: Float) { val layer = layerManager.getLayerById(layerId) as? ImageLayer layer?.let { val bitmap = it.image ?: return // 计算适应和居中后的尺寸 val scaleX = canvasWidth / bitmap.width val scaleY = canvasHeight / bitmap.height val fitScale = minOf(scaleX, scaleY).coerceAtMost(1f) val scaledWidth = bitmap.width * fitScale val scaledHeight = bitmap.height * fitScale val centerOffsetX = (canvasWidth - scaledWidth) / 2f val centerOffsetY = (canvasHeight - scaledHeight) / 2f // 适应后图像的中心点(在画布坐标系中,不考虑用户平移) val adaptedImageCenter = Offset( centerOffsetX + scaledWidth / 2f, centerOffsetY + scaledHeight / 2f ) // 计算画布坐标中的偏移(用户想要移动到的位置相对于适应后图像中心的偏移) val canvasOffset = canvasPosition - adaptedImageCenter // 将画布坐标的偏移转换为图像原始坐标系中的偏移 // 因为在 withTransform 中,translation 是在 fitScale 之前应用的 // 所以 translation 应该在图像原始坐标系中 // 变换顺序:用户平移(translation) -> fitScale -> centerOffset // 因此:canvasOffset = translation * fitScale // 所以:translation = canvasOffset / fitScale val translation = Offset( canvasOffset.x / fitScale, canvasOffset.y / fitScale ) val currentTransform = it.transform it.updateTransform( currentTransform.copy(translation = translation) ) } } /** * 更新图像层的位置(使用相对偏移) * @param layerId 图层ID * @param translation 相对于适应后图像中心的偏移(在适应后的坐标系中) */ fun updateImageLayerPosition(layerId: UUID, translation: Offset) { val layer = layerManager.getLayerById(layerId) as? ImageLayer layer?.let { val currentTransform = it.transform it.updateTransform( currentTransform.copy(translation = translation) ) } } /** * 更新图像层的旋转角度 */ fun updateImageLayerRotation(layerId: UUID, rotation: Float, pivot: Offset) { val layer = layerManager.getLayerById(layerId) as? ImageLayer layer?.let { val currentTransform = it.transform it.updateTransform( currentTransform.copy(rotation = rotation, pivot = pivot) ) } } /** * 更新图像层的缩放比例 */ fun updateImageLayerScale(layerId: UUID, scaleX: Float, scaleY: Float, pivot: Offset) { val layer = layerManager.getLayerById(layerId) as? ImageLayer layer?.let { val currentTransform = it.transform it.updateTransform( currentTransform.copy(scaleX = scaleX, scaleY = scaleY, pivot = pivot) ) } } /** * 更新图像层的完整变换 */ fun updateImageLayerTransform(layerId: UUID, transform: LayerTransform) { val layer = layerManager.getLayerById(layerId) as? ImageLayer layer?.let { it.updateTransform(transform) } } /** * 更新图像层的裁剪区域 * @param layerId 图层ID * @param cropRect 裁剪区域(在图像坐标系中,null表示取消裁剪) */ fun updateImageLayerCrop(layerId: UUID, cropRect: Rect?) { val layer = layerManager.getLayerById(layerId) as? ImageLayer layer?.let { val currentTransform = it.transform it.updateTransform( currentTransform.copy(cropRect = cropRect) ) } } /** * 获取当前激活的图像层 */ fun getActiveImageLayer(): ImageLayer? { val active = layerManager.activeLayer.value return active as? ImageLayer } // ==================== 背景层管理方法 ==================== /** * 检查指定图层是否为背景层 */ fun isBackgroundLayer(layer: Layer): Boolean { return layer.name == BACKGROUND_LAYER_NAME && layer is ImageLayer } /** * 检查指定图层是否为背景层(通过 ID) */ fun isBackgroundLayer(layerId: UUID): Boolean { val layer = layerManager.getLayerById(layerId) return layer != null && isBackgroundLayer(layer) } /** * 获取背景层,如果不存在则返回 null */ fun getBackgroundLayer(): ImageLayer? { return specialLayerHelper.getBackgroundLayer() } /** * 获取或创建背景层 * 如果不存在则创建新的背景层并添加到索引 0 */ fun getOrCreateBackgroundLayer(image: ImageBitmap): ImageLayer { return specialLayerHelper.getOrCreateBackgroundLayer(image) } /** * 更新背景层图像 * 如果背景层不存在,则创建新的 */ fun updateBackgroundLayer(image: ImageBitmap) { specialLayerHelper.updateBackgroundLayer(image) } /** * 检查是否存在背景层 */ fun hasBackgroundLayer(): Boolean { return specialLayerHelper.hasBackgroundLayer() } /** * 移除背景层(谨慎使用,通常不应该删除背景层) * 此方法主要用于清理或重置场景 */ fun removeBackgroundLayer(): Boolean { return specialLayerHelper.removeBackgroundLayer() } /** * 获取背景层的尺寸(如果存在) */ fun getBackgroundSize(): Pair? { return specialLayerHelper.getBackgroundSize() } /** * 将图层上移一层(防止移动背景层) */ fun moveLayerUp(layerId: UUID): Boolean { if (isBackgroundLayer(layerId)) { logger.warn("尝试移动背景层,操作被阻止") return false } return layerManager.moveLayerUp(layerId) } /** * 将图层下移一层(防止移动背景层) */ fun moveLayerDown(layerId: UUID): Boolean { if (isBackgroundLayer(layerId)) { logger.warn("尝试移动背景层,操作被阻止") return false } return layerManager.moveLayerDown(layerId) } } enum class EditorTool { SELECTION, SHAPE, IMAGE, MOVE } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/ShapeDrawingView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.material.MaterialTheme import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.ImageLayer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.animation.ShapeAnimationManager import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.coordinate.CoordinateConverter import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.handler.ShapeDrawingEventHandler import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.ShapeEnum import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.ShapeProperties import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.state.ShapeDrawingState import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.draggableTextField import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.ShapeDrawingPropertiesMenuDialog import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.CanvasView import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.LayerPanel import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.ControlPoint import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.ControlPointType import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.ImageLayerControlRenderer import cn.netdiscovery.monica.ui.widget.color.ColorSelectionDialog import cn.netdiscovery.monica.ui.widget.image.gesture.detectTransformGestures import cn.netdiscovery.monica.ui.widget.image.gesture.dragMotionEvent import cn.netdiscovery.monica.ui.widget.rightSideMenuBar import cn.netdiscovery.monica.ui.widget.toolTipButton import cn.netdiscovery.monica.ui.widget.image.ImageSizeCalculator import cn.netdiscovery.monica.i18n.getCurrentStringResource import org.slf4j.Logger import org.slf4j.LoggerFactory import kotlin.math.atan2 /** * 重构后的形状绘制视图 * 通过模块化设计降低耦合度,提高可维护性 * 实现模式一:绘制完成后颜色不变 * * @author Tony Shen * @date 2024/12/19 * @version V1.0 */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) @Composable fun shapeDrawing(state: ApplicationState) { val density = LocalDensity.current val i18nState = getCurrentStringResource() val editorController = remember { EditorController() } val drawingState = remember { ShapeDrawingState() } val animationManager = remember { ShapeAnimationManager() } // 观察激活图层状态 val activeLayer by editorController.layerManager.activeLayer.collectAsState() val coordinateConverter = remember(state.currentImage, density.density) { val originalSize = ImageSizeCalculator.getImagePixelSize(state) val displaySize = ImageSizeCalculator.getImageDisplayPixelSize(state, density.density) val scaleX = if (originalSize != null && displaySize != null) { originalSize.first.toFloat() / displaySize.first.toFloat() } else 1f val scaleY = if (originalSize != null && displaySize != null) { originalSize.second.toFloat() / displaySize.second.toFloat() } else 1f CoordinateConverter(scaleX, scaleY) } val eventHandler = remember { ShapeDrawingEventHandler(drawingState, coordinateConverter) } var showColorDialog by remember { mutableStateOf(false) } var showPropertiesDialog by remember { mutableStateOf(false) } var showDraggableTextField by remember { mutableStateOf(false) } val imageBitmap = state.currentImage?.toComposeImageBitmap() ?: run { logger.error("当前图像为空,无法进行绘制") return } fun syncShapeLayer() { editorController.replaceShapesInActiveLayer( drawingState.displayLines, drawingState.originalLines, drawingState.displayCircles, drawingState.originalCircles, drawingState.displayTriangles, drawingState.originalTriangles, drawingState.displayRectangles, drawingState.originalRectangles, drawingState.displayPolygons, drawingState.originalPolygons, drawingState.displayTexts, drawingState.originalTexts ) } LaunchedEffect(imageBitmap) { // 使用 EditorController 的统一方法管理背景层 editorController.updateBackgroundLayer(imageBitmap) } LaunchedEffect(Unit) { editorController.ensureActiveShapeLayer() editorController.selectTool(EditorTool.SHAPE) syncShapeLayer() } val (width, height) = ImageSizeCalculator.calculateImageSize(state) val displaySize = ImageSizeCalculator.getImageDisplayPixelSize(state, density.density) val bitmapWidth = displaySize?.first ?: 0 val bitmapHeight = displaySize?.second ?: 0 if (bitmapWidth <= 0 || bitmapHeight <= 0) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { androidx.compose.material.Text( text = "请先加载图片", color = Color.Gray ) } return } val scrollState = rememberScrollState() Box( modifier = Modifier .fillMaxSize() .background( brush = Brush.verticalGradient( colors = listOf( MaterialTheme.colors.background, MaterialTheme.colors.surface ) ) ) ) { Row( modifier = Modifier.fillMaxSize() ) { LayerPanel( editorController = editorController, state = state, modifier = Modifier .width(240.dp) .fillMaxHeight() ) Box( modifier = Modifier .weight(1f) .fillMaxHeight(), contentAlignment = Alignment.Center ) { Column( modifier = Modifier .fillMaxSize() .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { // 图像层拖动状态 var imageLayerDragStart by remember { mutableStateOf(null) } var imageLayerStartTranslation by remember { mutableStateOf(Offset.Zero) } // 控制点交互状态 var activeControlPoint by remember { mutableStateOf(null) } var controlPointDragStart by remember { mutableStateOf(null) } var initialTransform by remember { mutableStateOf(null) } val canvasModifier = Modifier .width(width) .height(height) .padding(8.dp) .shadow(1.dp) .background(Color.White) // 右键旋转(暂时移除滚轮缩放,后续可以添加) .pointerInput(activeLayer?.id, bitmapWidth, bitmapHeight, width, height) { val activeImageLayer = activeLayer as? ImageLayer val canvasWidth = with(density) { width.toPx() } val canvasHeight = with(density) { height.toPx() } if (activeImageLayer != null && !activeImageLayer.locked && !editorController.isBackgroundLayer(activeImageLayer)) { detectTransformGestures( panZoomLock = true, // 锁定平移和缩放,只允许旋转 onGesture = { centroid, pan, zoom, rotation, mainPointer, _ -> // 检查是否是右键(secondary button) // 注意:PointerButton 在某些 Compose 版本中可能不可用,暂时允许所有旋转操作 // TODO: 添加右键检测逻辑 val currentTransform = activeImageLayer.transform val newRotation = currentTransform.rotation + rotation // 计算中心点作为 pivot val center = ImageLayerControlRenderer.calculateImageCenter( activeImageLayer, canvasWidth, canvasHeight ) ?: return@detectTransformGestures // pivot 应该在图像原始坐标系中,相对于图像中心 // 图像中心在原始坐标系中是 (width/2, height/2) val imageBitmap = activeImageLayer.image val imageCenter = if (imageBitmap != null) { Offset(imageBitmap.width / 2f, imageBitmap.height / 2f) } else { Offset.Zero } editorController.updateImageLayerRotation( activeImageLayer.id, newRotation, imageCenter ) mainPointer.consume() } ) } } .dragMotionEvent( onDragStart = { pointerInputChange -> val activeImageLayer = activeLayer as? ImageLayer // 检查是否点击在控制点上 if (activeImageLayer != null && !activeImageLayer.locked && !editorController.isBackgroundLayer(activeImageLayer)) { val canvasWidth = with(density) { width.toPx() } val canvasHeight = with(density) { height.toPx() } val hitControlPoint = ImageLayerControlRenderer.hitTestControlPoint( pointerInputChange.position, activeImageLayer, canvasWidth, canvasHeight ) if (hitControlPoint != null) { // 开始拖动控制点 activeControlPoint = hitControlPoint controlPointDragStart = pointerInputChange.position initialTransform = activeImageLayer.transform.copy() pointerInputChange.consume() return@dragMotionEvent } } // 如果激活图层是图像层且未锁定,则直接拖动图像层 if (activeImageLayer != null && !activeImageLayer.locked) { imageLayerDragStart = pointerInputChange.position imageLayerStartTranslation = activeImageLayer.transform.translation pointerInputChange.consume() return@dragMotionEvent } // 如果激活图层是形状层,检查是否锁定 if (!editorController.canDrawOnActiveShapeLayer()) { state.showTray("形状层已锁定,无法绘制", "提示") pointerInputChange.consume() return@dragMotionEvent } eventHandler.handleMouseDown(pointerInputChange.position) pointerInputChange.consume() }, onDrag = { pointerInputChange -> val activeImageLayer = activeLayer as? ImageLayer // 如果正在拖动控制点 if (activeImageLayer != null && activeControlPoint != null && controlPointDragStart != null && initialTransform != null) { val currentPos = pointerInputChange.position val startPos = controlPointDragStart!! val controlPoint = activeControlPoint!! when (controlPoint.type) { ControlPointType.ROTATION_HANDLE -> { // 拖动旋转手柄进行旋转 val canvasWidth = with(density) { width.toPx() } val canvasHeight = with(density) { height.toPx() } val center = ImageLayerControlRenderer.calculateImageCenter( activeImageLayer, canvasWidth, canvasHeight ) ?: return@dragMotionEvent val startAngle = atan2( startPos.y - center.y, startPos.x - center.x ) val currentAngle = atan2( currentPos.y - center.y, currentPos.x - center.x ) val rotationDelta = Math.toDegrees((currentAngle - startAngle).toDouble()).toFloat() // pivot 应该在图像原始坐标系中,相对于图像中心 // 图像中心在原始坐标系中是 (width/2, height/2) val imageBitmap = activeImageLayer.image val imageCenter = if (imageBitmap != null) { Offset(imageBitmap.width / 2f, imageBitmap.height / 2f) } else { Offset.Zero } val newRotation = initialTransform!!.rotation + rotationDelta editorController.updateImageLayerRotation( activeImageLayer.id, newRotation, imageCenter ) } ControlPointType.CORNER_TOP_LEFT, ControlPointType.CORNER_TOP_RIGHT, ControlPointType.CORNER_BOTTOM_LEFT, ControlPointType.CORNER_BOTTOM_RIGHT -> { // 拖动角点进行缩放 val canvasWidth = with(density) { width.toPx() } val canvasHeight = with(density) { height.toPx() } val center = ImageLayerControlRenderer.calculateImageCenter( activeImageLayer, canvasWidth, canvasHeight ) ?: return@dragMotionEvent val startDistance = (startPos - center).getDistance() val currentDistance = (currentPos - center).getDistance() if (startDistance > 0f) { val scaleFactor = currentDistance / startDistance val newScaleX = (initialTransform!!.scaleX * scaleFactor).coerceIn(0.1f, 10f) val newScaleY = (initialTransform!!.scaleY * scaleFactor).coerceIn(0.1f, 10f) // pivot 应该在图像原始坐标系中,相对于图像中心 // 图像中心在原始坐标系中是 (width/2, height/2) val imageBitmap = activeImageLayer.image val imageCenter = if (imageBitmap != null) { Offset(imageBitmap.width / 2f, imageBitmap.height / 2f) } else { Offset.Zero } editorController.updateImageLayerScale( activeImageLayer.id, newScaleX, newScaleY, imageCenter ) } } // 裁剪控制点暂时不处理,后续可以添加 ControlPointType.CROP_TOP_LEFT, ControlPointType.CROP_TOP_RIGHT, ControlPointType.CROP_BOTTOM_LEFT, ControlPointType.CROP_BOTTOM_RIGHT, ControlPointType.CROP_TOP, ControlPointType.CROP_BOTTOM, ControlPointType.CROP_LEFT, ControlPointType.CROP_RIGHT -> { // TODO: 实现裁剪控制点的拖动逻辑 } } pointerInputChange.consume() return@dragMotionEvent } // 如果正在拖动图像层 if (activeImageLayer != null && imageLayerDragStart != null && !activeImageLayer.locked) { val canvasWidth = with(density) { width.toPx() } val canvasHeight = with(density) { height.toPx() } // 使用新的方法,传入画布坐标和尺寸 editorController.updateImageLayerPosition( activeImageLayer.id, pointerInputChange.position, canvasWidth, canvasHeight ) pointerInputChange.consume() return@dragMotionEvent } // 否则,处理形状绘制(仅在形状层激活时) if (!editorController.canDrawOnActiveShapeLayer()) { pointerInputChange.consume() return@dragMotionEvent } val currentShapes = eventHandler.handleMouseMove(pointerInputChange.position) currentShapes.forEach { (key, shape) -> when (shape) { is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Line -> drawingState.displayLines[key] = shape is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Circle -> drawingState.displayCircles[key] = shape is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Triangle -> drawingState.displayTriangles[key] = shape is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Rectangle -> drawingState.displayRectangles[key] = shape is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Polygon -> drawingState.displayPolygons[key] = shape is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Text -> drawingState.displayTexts[key] = shape } } syncShapeLayer() pointerInputChange.consume() }, onDragEnd = { pointerInputChange -> val activeImageLayer = activeLayer as? ImageLayer // 如果正在拖动控制点,结束拖动 if (activeImageLayer != null && activeControlPoint != null) { activeControlPoint = null controlPointDragStart = null initialTransform = null pointerInputChange.consume() return@dragMotionEvent } // 如果正在拖动图像层,结束拖动 if (activeImageLayer != null && imageLayerDragStart != null) { imageLayerDragStart = null pointerInputChange.consume() return@dragMotionEvent } // 否则,处理形状绘制结束 if (!editorController.canDrawOnActiveShapeLayer()) { pointerInputChange.consume() return@dragMotionEvent } val result = eventHandler.handleMouseUp(pointerInputChange.position, bitmapWidth, bitmapHeight) result?.let { (key, shape) -> val shapeType = when (shape) { is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Line -> "Line" is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Circle -> "Circle" is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Triangle -> "Triangle" is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Rectangle -> "Rectangle" is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Polygon -> "Polygon" is cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Text -> "Text" else -> "Unknown" } animationManager.addAnimatedShape(shapeType, key) } syncShapeLayer() pointerInputChange.consume() } ) CanvasView( editorController = editorController, drawingState = drawingState, animationManager = animationManager, modifier = canvasModifier ) } // 将 TextInputDialog 放在画布所在的 Box 中,使其相对于画布居中 if (showDraggableTextField) { // 计算画布的实际显示尺寸(像素),应该等于 bitmapWidth 和 bitmapHeight val canvasDisplayWidthPx = bitmapWidth.toFloat() val canvasDisplayHeightPx = bitmapHeight.toFloat() TextInputDialog( modifier = Modifier.width(250.dp).height(130.dp), canvasWidthPx = canvasDisplayWidthPx, canvasHeightPx = canvasDisplayHeightPx, density = density, currentText = drawingState.currentText, currentShapeProperty = drawingState.currentShapeProperty, onTextChanged = { drawingState.updateTextState(it) }, onDragged = { offset -> // offset 是相对于画布中心的偏移(像素) // 由于画布显示尺寸等于图像显示尺寸,offset 可以直接使用 val textPosition = CoordinateSystem.calculateTextPosition( dragOffset = offset, imageWidth = bitmapWidth, imageHeight = bitmapHeight, density = density, textFieldWidth = 250f, textFieldHeight = 130f, fontSize = drawingState.currentShapeProperty.fontSize ) val textValidation = CoordinateSystem.validateOffset(textPosition, bitmapWidth, bitmapHeight) if (textValidation.isValid) { val displayText = cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.Text( textPosition, drawingState.currentText, drawingState.currentShapeProperty ) val originalText = coordinateConverter.convertTextToOriginal(displayText) drawingState.addShape(textPosition, displayText, originalText) drawingState.recordLastDrawnShape(textPosition, "Text") logger.info("添加文字: '${drawingState.currentText}' 在位置 $textPosition") syncShapeLayer() drawingState.updateTextState("") } else { logger.warn("文本位置无效: ${textValidation.message}") } showDraggableTextField = false } ) } } } rightSideMenuBar(modifier = Modifier.align(Alignment.CenterEnd)) { toolTipButton( text = i18nState.get("select_color"), painter = painterResource("images/doodle/color.png"), onClick = { showColorDialog = true } ) toolTipButton( text = i18nState.get("change_properties"), painter = painterResource("images/doodle/brush.png"), onClick = { showPropertiesDialog = true } ) ShapeSelectionButtons(drawingState) toolTipButton( text = i18nState.get("add_text"), painter = painterResource("images/shapedrawing/text.png"), onClick = { showDraggableTextField = true } ) toolTipButton( text = i18nState.get("clear"), painter = painterResource("images/doodle/clear.png"), onClick = { drawingState.clearAllShapes() animationManager.clearAllAnimations() syncShapeLayer() } ) toolTipButton( text = i18nState.get("save"), painter = painterResource("images/doodle/save.png"), onClick = { // 使用显示尺寸而不是原始像素尺寸,确保导出和显示一致 // 注意:Canvas 有 padding(8.dp),所以实际绘制区域需要减去 padding val displaySize = ImageSizeCalculator.getImageDisplayPixelSize(state, density.density) val current = state.currentImage if (displaySize == null || current == null) { logger.warn("当前无法导出:缺少有效图像") return@toolTipButton } // 计算减去 padding 后的实际绘制区域尺寸(Canvas 内部 drawScope.size) val paddingPx = with(density) { (8.dp * 2).toPx() } // 左右各 8.dp,上下各 8.dp val actualCanvasWidth = (displaySize.first - paddingPx).toInt().coerceAtLeast(1) val actualCanvasHeight = (displaySize.second - paddingPx).toInt().coerceAtLeast(1) val flattened = editorController.exportBufferedImage( width = actualCanvasWidth, height = actualCanvasHeight, density = density ) state.addQueue(current) state.currentImage = flattened state.closePreviewWindow() } ) } if (showColorDialog) { ColorSelectionDialog( drawingState.currentShapeProperty.color, onDismiss = { showColorDialog = false }, onNegativeClick = { showColorDialog = false }, onPositiveClick = { color: Color -> showColorDialog = false drawingState.updateColor(color) logger.info("颜色已更改: ${color} (仅影响新绘制的形状)") } ) } if (showPropertiesDialog) { ShapeDrawingPropertiesMenuDialog(drawingState.currentShapeProperty) { updatedProperties -> drawingState.updateShapeProperty(updatedProperties) logger.info("属性已更新: fontSize=${updatedProperties.fontSize}, alpha=${updatedProperties.alpha} (仅影响新绘制的形状)") showPropertiesDialog = false } } } } /** * 形状选择按钮组 */ @Composable private fun ShapeSelectionButtons(drawingState: ShapeDrawingState) { val i18nState = getCurrentStringResource() val shapes = listOf( Triple(ShapeEnum.Line, "images/shapedrawing/line.png", i18nState.get("line")), Triple(ShapeEnum.Circle, "images/shapedrawing/circle.png", i18nState.get("circle")), Triple(ShapeEnum.Triangle, "images/shapedrawing/triangle.png", i18nState.get("triangle")), Triple(ShapeEnum.Rectangle, "images/shapedrawing/rectangle.png", i18nState.get("rectangle")), Triple(ShapeEnum.Polygon, "images/shapedrawing/polygon.png", i18nState.get("polygon")) ) shapes.forEach { (shape, icon, text) -> toolTipButton( text = text, painter = painterResource(icon), onClick = { drawingState.selectShape(shape) } ) } } /** * 文字输入对话框组件 */ @Composable private fun TextInputDialog( modifier: Modifier, canvasWidthPx: Float, canvasHeightPx: Float, density: androidx.compose.ui.unit.Density, currentText: String, currentShapeProperty: ShapeProperties, onTextChanged: (String) -> Unit, onDragged: (Offset) -> Unit ) { draggableTextField( modifier = modifier, canvasWidthPx = canvasWidthPx, canvasHeightPx = canvasHeightPx, density = density, text = currentText, onTextChanged = onTextChanged, onDragged = onDragged ) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/ShapeDrawingViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.CanvasDrawScope import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.geometry.CanvasDrawer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.geometry.Style import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.* import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.TextDrawer /** * 形状绘制视图模型 * 负责管理各种几何形状的绘制逻辑 * * @author Tony Shen * @date 2024/11/21 16:09 * @version V1.0 */ class ShapeDrawingViewModel { /** * 绘制所有形状到画布 */ fun drawShape( canvasDrawer: CanvasDrawer, lines: Map, circles: Map, triangles: Map, rectangles: Map, polygons: Map, texts: Map, saveFlag: Boolean = false ) { drawLines(canvasDrawer, lines, saveFlag) drawCircles(canvasDrawer, circles, saveFlag) drawTriangles(canvasDrawer, triangles, saveFlag) drawRectangles(canvasDrawer, rectangles, saveFlag) drawPolygons(canvasDrawer, polygons, saveFlag) drawTexts(canvasDrawer, texts) } /** * 保存画布为位图 */ fun saveCanvasToBitmap( density: Density, lines: Map, circles: Map, triangles: Map, rectangles: Map, polygons: Map, texts: Map, image: ImageBitmap, state: ApplicationState ) { val bitmapWidth = image.width val bitmapHeight = image.height val drawScope = CanvasDrawScope() val size = Size(bitmapWidth.toFloat(), bitmapHeight.toFloat()) val canvas = Canvas(image) val canvasDrawer = CanvasDrawer(TextDrawer, canvas) drawScope.draw( density = density, layoutDirection = LayoutDirection.Ltr, canvas = canvas, size = size ) { state.closePreviewWindow() drawShape(canvasDrawer, lines, circles, triangles, rectangles, polygons, texts, true) } state.addQueue(state.currentImage!!) state.currentImage = image.toAwtImage() } // 绘制线条 private fun drawLines(canvasDrawer: CanvasDrawer, lines: Map, saveFlag: Boolean) { lines.forEach { (_, line) -> if (line.from != Offset.Unspecified && !saveFlag) { canvasDrawer.point(line.from, line.shapeProperties.color) } if (line.from != Offset.Unspecified && line.to != Offset.Unspecified) { val style = createStyle(line.shapeProperties) canvasDrawer.line(line.from, line.to, style) } } } // 绘制圆形 private fun drawCircles(canvasDrawer: CanvasDrawer, circles: Map, saveFlag: Boolean) { circles.forEach { (_, circle) -> if (circle.center != Offset.Unspecified && !saveFlag) { canvasDrawer.point(circle.center, circle.shapeProperties.color) } if (circle.center != Offset.Unspecified) { val style = createStyle(circle.shapeProperties) canvasDrawer.circle(circle.center, circle.radius, style) } } } // 绘制三角形 private fun drawTriangles(canvasDrawer: CanvasDrawer, triangles: Map, saveFlag: Boolean) { triangles.forEach { (_, triangle) -> if (triangle.first != Offset.Unspecified && !saveFlag) { canvasDrawer.point(triangle.first, triangle.shapeProperties.color) } val style = createStyle(triangle.shapeProperties) // 绘制三角形的边 if (triangle.second != Offset.Unspecified && !saveFlag) { canvasDrawer.point(triangle.second!!, triangle.shapeProperties.color) canvasDrawer.line(triangle.first, triangle.second, style) } // 绘制完整的三角形 if (isValidTriangle(triangle)) { val points = listOf(triangle.first, triangle.second!!, triangle.third!!) canvasDrawer.polygon(points, style) } } } // 绘制矩形 private fun drawRectangles(canvasDrawer: CanvasDrawer, rectangles: Map, saveFlag: Boolean) { rectangles.forEach { (_, rect) -> if (rect.rectFirst != Offset.Unspecified && !saveFlag) { canvasDrawer.point(rect.rectFirst, rect.shapeProperties.color) } if (isValidRectangle(rect)) { val points = listOf(rect.tl, rect.bl, rect.br, rect.tr) val style = createStyle(rect.shapeProperties) canvasDrawer.polygon(points, style) } } } // 绘制多边形 private fun drawPolygons(canvasDrawer: CanvasDrawer, polygons: Map, saveFlag: Boolean) { polygons.forEach { (_, polygon) -> if (polygon.points.isNotEmpty()) { if (polygon.points[0] != Offset.Unspecified && !saveFlag) { canvasDrawer.point(polygon.points[0], polygon.shapeProperties.color) } val style = createStyle(polygon.shapeProperties) // 绘制多边形的边 if (polygon.points.size > 1 && polygon.points[1] != Offset.Unspecified && !saveFlag) { canvasDrawer.point(polygon.points[1], polygon.shapeProperties.color) canvasDrawer.line(polygon.points[0], polygon.points[1], style) } canvasDrawer.polygon(polygon.points, style) } } } // 绘制文本 private fun drawTexts(canvasDrawer: CanvasDrawer, texts: Map) { texts.forEach { (_, text) -> if (text.point != Offset.Unspecified) { val textList = listOf(text.message) canvasDrawer.text(text.point, textList, text.shapeProperties.color, text.shapeProperties.fontSize) } } } // 创建样式对象 private fun createStyle(properties: cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.ShapeProperties): Style { return Style( name = null, color = properties.color, border = properties.border, equalityGroup = null, fill = properties.fill, scale = 1f, alpha = properties.alpha, bounded = true ) } // 验证三角形是否有效 private fun isValidTriangle(triangle: Triangle): Boolean { return triangle.first != Offset.Unspecified && triangle.second != Offset.Unspecified && triangle.third != Offset.Unspecified } // 验证矩形是否有效 private fun isValidRectangle(rect: Rectangle): Boolean { return rect.tl != Offset.Unspecified && rect.bl != Offset.Unspecified && rect.br != Offset.Unspecified && rect.tr != Offset.Unspecified } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/animation/ShapeAnimationManager.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.animation import androidx.compose.runtime.* import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import kotlinx.coroutines.launch import kotlinx.coroutines.delay import org.slf4j.Logger import org.slf4j.LoggerFactory /** * 动画形状数据类 */ data class AnimatedShape( val key: String, val shapeType: String, val startTime: Long, val duration: Long = 800L, val startScale: Float = 0.3f, val endScale: Float = 1.2f, val startAlpha: Float = 0.1f, val endAlpha: Float = 0.8f, val highlightColor: Color = Color.Cyan, val pulseEffect: Boolean = true ) /** * 形状动画管理器 * 负责管理形状绘制时的动画效果 * * @author Tony Shen * @date 2025/9/8 * @version V1.0 */ class ShapeAnimationManager { private val logger: Logger = LoggerFactory.getLogger(this::class.java) // 动画状态管理 var animatedShapes by mutableStateOf>(emptyMap()) private set /** * 添加动画形状 */ fun addAnimatedShape(shapeType: String, key: Offset) { // 检查 key 是否有效 if (key == Offset.Unspecified) { logger.warn("无法添加动画形状: key 是 Offset.Unspecified") return } val shapeKey = "${shapeType}_${key.x}_${key.y}" val currentTime = System.currentTimeMillis() // 根据形状类型设置不同的动画参数 val animationParams = when (shapeType) { "Circle" -> AnimatedShape( key = shapeKey, shapeType = shapeType, startTime = currentTime, duration = 1000L, highlightColor = Color.Cyan ) "Line" -> AnimatedShape( key = shapeKey, shapeType = shapeType, startTime = currentTime, duration = 600L, highlightColor = Color.Magenta ) "Triangle" -> AnimatedShape( key = shapeKey, shapeType = shapeType, startTime = currentTime, duration = 800L, highlightColor = Color.Green ) "Rectangle" -> AnimatedShape( key = shapeKey, shapeType = shapeType, startTime = currentTime, duration = 700L, highlightColor = Color.Blue ) else -> AnimatedShape( key = shapeKey, shapeType = shapeType, startTime = currentTime, highlightColor = Color.Yellow ) } animatedShapes = animatedShapes + (shapeKey to animationParams) // 动画结束后自动移除 kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default).launch { delay(animationParams.duration + 200) animatedShapes = animatedShapes - shapeKey } logger.info("添加动画形状: $shapeType") } /** * 缓动插值函数 */ fun lerp(start: Float, end: Float, fraction: Float): Float { return start + (end - start) * fraction } /** * 缓动函数 - 缓入缓出 */ fun easeInOutCubic(t: Float): Float { return if (t < 0.5f) { 4f * t * t * t } else { val temp = -2f * t + 2f 1f - (temp * temp * temp) / 2f } } /** * 脉冲效果函数 */ fun pulseEffect(progress: Float): Float { return (kotlin.math.sin((progress * kotlin.math.PI * 4).toDouble()).toFloat() + 1f) / 2f } /** * 清除所有动画 */ fun clearAllAnimations() { animatedShapes = emptyMap() logger.info("清除所有动画") } /** * 绘制动画高亮效果 */ fun DrawScope.drawAnimationHighlight( animatedShape: AnimatedShape, scale: Float, alpha: Float, displayLines: Map, displayCircles: Map, displayTriangles: Map, displayRectangles: Map, displayPolygons: Map ) { val key = animatedShape.key val shapeType = animatedShape.shapeType val highlightColor = animatedShape.highlightColor // 添加脉冲效果 val pulseAlpha = if (animatedShape.pulseEffect) { alpha * (0.5f + 0.5f * pulseEffect(scale)) } else { alpha } // 根据形状类型获取位置和绘制动画效果 when (shapeType) { "Line" -> { val lineKey = Offset( key.split("_")[1].toFloat(), key.split("_")[2].toFloat() ) displayLines[lineKey]?.let { line -> // 绘制多层效果 drawLine( color = highlightColor.copy(alpha = pulseAlpha * 0.8f), start = line.from, end = line.to, strokeWidth = 12f * scale, cap = androidx.compose.ui.graphics.StrokeCap.Round ) drawLine( color = Color.White.copy(alpha = pulseAlpha * 0.6f), start = line.from, end = line.to, strokeWidth = 6f * scale, cap = androidx.compose.ui.graphics.StrokeCap.Round ) } } "Circle" -> { val circleKey = Offset( key.split("_")[1].toFloat(), key.split("_")[2].toFloat() ) displayCircles[circleKey]?.let { circle -> // 绘制外圈和内圈 drawCircle( color = highlightColor.copy(alpha = pulseAlpha * 0.4f), radius = circle.radius * scale * 1.2f, center = circle.center, style = Stroke(width = 4f * scale) ) drawCircle( color = Color.White.copy(alpha = pulseAlpha * 0.6f), radius = circle.radius * scale, center = circle.center, style = Stroke(width = 2f * scale) ) } } "Triangle" -> { val triangleKey = Offset( key.split("_")[1].toFloat(), key.split("_")[2].toFloat() ) displayTriangles[triangleKey]?.let { triangle -> val path = androidx.compose.ui.graphics.Path().apply { moveTo(triangle.first.x, triangle.first.y) lineTo(triangle.second?.x ?: triangle.first.x, triangle.second?.y ?: triangle.first.y) lineTo(triangle.third?.x ?: triangle.first.x, triangle.third?.y ?: triangle.first.y) close() } drawPath( path = path, color = highlightColor.copy(alpha = pulseAlpha * 0.5f), style = Stroke(width = 8f * scale) ) drawPath( path = path, color = Color.White.copy(alpha = pulseAlpha * 0.7f), style = Stroke(width = 3f * scale) ) } } "Rectangle" -> { val rectKey = Offset( key.split("_")[1].toFloat(), key.split("_")[2].toFloat() ) displayRectangles[rectKey]?.let { rect -> val rectSize = androidx.compose.ui.geometry.Size( rect.br.x - rect.tl.x, rect.br.y - rect.tl.y ) drawRect( color = highlightColor.copy(alpha = pulseAlpha * 0.4f), topLeft = rect.tl, size = rectSize, style = Stroke(width = 6f * scale) ) drawRect( color = Color.White.copy(alpha = pulseAlpha * 0.6f), topLeft = rect.tl, size = rectSize, style = Stroke(width = 2f * scale) ) } } "Polygon" -> { val polygonKey = Offset( key.split("_")[1].toFloat(), key.split("_")[2].toFloat() ) displayPolygons[polygonKey]?.let { polygon -> if (polygon.points.isNotEmpty()) { val path = androidx.compose.ui.graphics.Path().apply { moveTo(polygon.points[0].x, polygon.points[0].y) polygon.points.drop(1).forEach { point -> lineTo(point.x, point.y) } close() } drawPath( path = path, color = highlightColor.copy(alpha = pulseAlpha * 0.5f), style = Stroke(width = 8f * scale) ) drawPath( path = path, color = Color.White.copy(alpha = pulseAlpha * 0.7f), style = Stroke(width = 3f * scale) ) } } } } } /** * 绘制所有动画效果 */ fun DrawScope.drawAllAnimations( displayLines: Map, displayCircles: Map, displayTriangles: Map, displayRectangles: Map, displayPolygons: Map ) { val currentTime = System.currentTimeMillis() animatedShapes.forEach { (key, animatedShape) -> val elapsed = currentTime - animatedShape.startTime val rawProgress = (elapsed.toFloat() / animatedShape.duration).coerceIn(0f, 1f) if (rawProgress < 1f) { // 使用缓动函数改进动画效果 val easedProgress = easeInOutCubic(rawProgress) val scale = lerp(animatedShape.startScale, animatedShape.endScale, easedProgress) val alpha = lerp(animatedShape.startAlpha, animatedShape.endAlpha, easedProgress) // 绘制优化的动画高亮效果 drawAnimationHighlight( animatedShape, scale, alpha, displayLines, displayCircles, displayTriangles, displayRectangles, displayPolygons ) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/coordinate/CoordinateConverter.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.coordinate import androidx.compose.ui.geometry.Offset import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.* /** * 坐标转换器 * 负责显示坐标和原始坐标之间的转换 * * @author Tony Shen * @date 2025/9/8 * @version V1.0 */ class CoordinateConverter(private val scaleX: Float, private val scaleY: Float) { /** * 显示坐标转原始坐标 */ fun displayToOriginal(displayOffset: Offset): Offset { return Offset(displayOffset.x * scaleX, displayOffset.y * scaleY) } /** * 转换线段坐标 */ fun convertLineToOriginal(displayLine: Line): Line { val originalFrom = displayToOriginal(displayLine.from) val originalTo = displayToOriginal(displayLine.to) return Line(originalFrom, originalTo, displayLine.shapeProperties) } /** * 转换圆形坐标 */ fun convertCircleToOriginal(displayCircle: Circle): Circle { val originalCenter = displayToOriginal(displayCircle.center) val originalRadius = displayCircle.radius * ((scaleX + scaleY) / 2f) // 平均缩放半径 return Circle(originalCenter, originalRadius, displayCircle.shapeProperties) } /** * 转换三角形坐标 */ fun convertTriangleToOriginal(displayTriangle: Triangle): Triangle { val originalFirst = displayToOriginal(displayTriangle.first) val originalSecond = displayTriangle.second?.let { displayToOriginal(it) } val originalThird = displayTriangle.third?.let { displayToOriginal(it) } return Triangle(originalFirst, originalSecond, originalThird, displayTriangle.shapeProperties) } /** * 转换矩形坐标 */ fun convertRectangleToOriginal(displayRect: Rectangle): Rectangle { val originalTl = displayToOriginal(displayRect.tl) val originalBl = displayToOriginal(displayRect.bl) val originalBr = displayToOriginal(displayRect.br) val originalTr = displayToOriginal(displayRect.tr) val originalFirst = displayToOriginal(displayRect.rectFirst) return Rectangle(originalTl, originalBl, originalBr, originalTr, originalFirst, displayRect.shapeProperties) } /** * 转换多边形坐标 */ fun convertPolygonToOriginal(displayPolygon: Polygon): Polygon { val originalPoints = displayPolygon.points.map { displayToOriginal(it) } return Polygon(originalPoints, displayPolygon.shapeProperties) } /** * 转换文字坐标 */ fun convertTextToOriginal(displayText: Text): Text { val originalPoint = displayToOriginal(displayText.point) return Text(originalPoint, displayText.message, displayText.shapeProperties) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/geometry/CanvasDrawer.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.geometry import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.* /** * 文本绘制器接口 * 定义了文本绘制的契约 */ interface TextDrawer { /** * 在指定位置绘制文本 * * @param canvas 画布 * @param pos 位置 * @param text 文本列表 * @param color 颜色 * @param fontSize 字体大小 */ fun text(canvas: Canvas, pos: Offset, text: List, color: Color, fontSize: Float) } /** * 画布绘制器 * 实现了Drawer接口,提供具体的绘制功能 * * @param textDrawer 文本绘制器 * @param canvas 画布对象 * * @author Tony Shen * @date 2024/11/20 11:07 * @version V1.0 */ class CanvasDrawer( private val textDrawer: TextDrawer, private val canvas: Canvas ) : Drawer { override fun point(offset: Offset, color: Color) { try { canvas.drawCircle(offset, 4f, Paint().apply { this.color = color }) } catch (e: Exception) { // 记录错误但不中断绘制 println("Error drawing point at $offset: ${e.message}") } } override fun circle(center: Offset, radius: Float, style: Style) { try { style.styled { paint -> canvas.drawCircle(center, radius, paint) } } catch (e: Exception) { println("Error drawing circle at $center with radius $radius: ${e.message}") } } override fun line(from: Offset, to: Offset, style: Style) { try { style.styled { paint -> canvas.drawLine(from, to, paint) } } catch (e: Exception) { println("Error drawing line from $from to $to: ${e.message}") } } override fun polygon(points: List, style: Style) { if (points.size < 2) { println("Warning: Polygon must have at least 2 points") return } try { val path = Path().apply { moveTo(points[0].x, points[0].y) points.drop(1).forEach { point -> lineTo(point.x, point.y) } close() } style.styled { paint -> canvas.drawPath(path, paint) } } catch (e: Exception) { println("Error drawing polygon with ${points.size} points: ${e.message}") } } override fun text(pos: Offset, text: List, color: Color, fontSize: Float) { try { textDrawer.text(canvas, pos, text, color, fontSize) } catch (e: Exception) { println("Error drawing text at $pos: ${e.message}") } } override fun angle(center: Offset, from: Float, to: Float, style: Style) { try { val rect = Rect(center, 50f * style.scale) style.styled { paint -> canvas.drawArcRad(rect, -from, from - to, true, paint) } } catch (e: Exception) { println("Error drawing angle at $center: ${e.message}") } } /** * 绘制矩形 * * @param rect 矩形区域 * @param style 样式 */ fun rectangle(rect: Rect, style: Style) { try { val path = Path().apply { addRect(rect) } style.styled { paint -> canvas.drawPath(path, paint) } } catch (e: Exception) { println("Error drawing rectangle: ${e.message}") } } /** * 绘制椭圆 * * @param rect 椭圆边界矩形 * @param style 样式 */ fun ellipse(rect: Rect, style: Style) { try { style.styled { paint -> canvas.drawOval(rect, paint) } } catch (e: Exception) { println("Error drawing ellipse: ${e.message}") } } } /** * Style扩展函数:应用样式到绘制操作 * * @param action 绘制操作,接收Paint参数 */ private fun Style.styled(action: (Paint) -> Unit) { // 绘制填充 if (fill) { action(Paint().apply { color = this@styled.color style = PaintingStyle.Fill alpha = this@styled.alpha }) } // 绘制边框 if (border != Border.No) { action(Paint().apply { color = this@styled.color style = PaintingStyle.Stroke pathEffect = border.effect?.let { PathEffect.dashPathEffect(it) } strokeWidth = 4.2f * scale alpha = this@styled.alpha }) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/geometry/Drawer.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.geometry import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.shapedrawing.geometry.Drawer * @author: Tony Shen * @date: 2024/11/20 11:42 * @version: V1.0 <描述当前版本功能> */ interface Drawer { val zoom: Float get() = 1f val bounds: Rect get() = Rect(-100f, -100f, 100f, 100f) fun point(offset: Offset, color: Color) fun circle(center: Offset, radius: Float, style: Style) fun line(from: Offset, to: Offset, style: Style) fun polygon(points: List, style: Style) fun text(pos: Offset, text: List, color: Color, fontSize: Float) fun angle(center: Offset, from: Float, to: Float, style: Style) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/geometry/Style.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.geometry import androidx.compose.ui.graphics.Color /** * 边框样式枚举 * 定义了不同的边框效果 * * @author Tony Shen * @date 2024/11/20 11:45 * @version V1.0 */ enum class Border(val effect: FloatArray?, val description: String) { No(null, "无边框"), Dot(floatArrayOf(5f, 5f), "点状边框"), Dash(floatArrayOf(25f, 25f), "虚线边框"), DashDot(floatArrayOf(25f, 10f, 5f, 10f), "点划线边框"), Line(null, "实线边框"); companion object { /** * 根据描述获取边框样式 */ fun fromDescription(description: String): Border? = values().find { it.description == description } } } /** * 等分组枚举 * 用于分组相关的样式 */ enum class EqualityGroup(val description: String) { Equal1("等分组1"), Equal2("等分组2"), Equal3("等分组3"), EqualV("等分组V"), EqualO("等分组O"); companion object { /** * 根据描述获取等分组 */ fun fromDescription(description: String): EqualityGroup? = values().find { it.description == description } } } /** * 文本跨度处理函数 * 将包含下划线的文本分割为多个部分 * * @param text 输入文本 * @return 分割后的文本列表 */ fun spans(text: String): List = buildList { var last = 0 while (true) { var next = text.indexOf('_', last) if (next == text.length - 1 || next == -1) next = text.length add(text.substring(last, next)) if (next == text.length) break if (text[next + 1] == '{') { last = text.indexOf('}', next + 1) if (last == 0) error("Expected '}'") add(text.substring(next + 2, last)) last++ } else { add(text[next + 1].toString()) last = next + 2 } } } /** * 样式数据类 * 定义了绘制形状时的视觉样式 * * @param name 样式名称列表 * @param color 颜色 * @param border 边框样式 * @param equalityGroup 等分组 * @param fill 是否填充 * @param scale 缩放比例 * @param alpha 透明度 * @param bounded 是否受边界限制 */ data class Style( val name: List? = null, val color: Color, val border: Border, val equalityGroup: EqualityGroup? = null, val fill: Boolean = false, val scale: Float = 1f, val alpha: Float = 1f, val bounded: Boolean = true ) { init { require(scale > 0f) { "Scale must be positive" } require(alpha in 0f..1f) { "Alpha must be between 0.0 and 1.0" } } /** * 检查样式是否有效 */ fun isValid(): Boolean = scale > 0f && alpha in 0f..1f /** * 创建带有新颜色的副本 */ fun withColor(newColor: Color): Style = copy(color = newColor) /** * 创建带有新边框的副本 */ fun withBorder(newBorder: Border): Style = copy(border = newBorder) /** * 创建带有新透明度的副本 */ fun withAlpha(newAlpha: Float): Style = copy(alpha = newAlpha.coerceIn(0f, 1f)) /** * 创建带有新缩放比例的副本 */ fun withScale(newScale: Float): Style = copy(scale = newScale.coerceAtLeast(0.1f)) /** * 创建带有新填充状态的副本 */ fun withFill(newFill: Boolean): Style = copy(fill = newFill) /** * 创建带有新边界限制的副本 */ fun withBounded(newBounded: Boolean): Style = copy(bounded = newBounded) companion object { /** * 默认样式 */ val DEFAULT = Style( color = Color.Black, border = Border.Line, fill = false, scale = 1f, alpha = 1f, bounded = true ) /** * 透明样式 */ val TRANSPARENT = Style( color = Color.Transparent, border = Border.No, fill = false, scale = 1f, alpha = 0f, bounded = true ) /** * 填充样式 */ val FILLED = Style( color = Color.Blue, border = Border.Line, fill = true, scale = 1f, alpha = 1f, bounded = true ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/handler/ShapeDrawingEventHandler.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.handler import androidx.compose.ui.geometry.Offset import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.coordinate.CoordinateConverter import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.* import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.ShapeEnum import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.state.ShapeDrawingState import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.CoordinateSystem import cn.netdiscovery.monica.ui.widget.image.gesture.MotionEvent import cn.netdiscovery.monica.utils.logger import org.slf4j.Logger /** * 形状绘制事件处理器 * 处理各种形状的绘制逻辑,分离业务逻辑 * * @author Tony Shen * @date 2025/9/8 * @version V1.0 */ class ShapeDrawingEventHandler( private val state: ShapeDrawingState, private val coordinateConverter: CoordinateConverter ) { private val logger: Logger = logger() /** * 处理鼠标按下事件 */ fun handleMouseDown(position: Offset) { state.updatePosition(position) state.updateMotionEvent(MotionEvent.Down) when (state.currentShape) { ShapeEnum.Line -> handleLineDown(position) ShapeEnum.Circle -> handleCircleDown(position) ShapeEnum.Triangle -> handleTriangleDown(position) ShapeEnum.Rectangle -> handleRectangleDown(position) ShapeEnum.Polygon -> handlePolygonDown(position) else -> Unit } } /** * 处理鼠标移动事件 */ fun handleMouseMove(position: Offset): Map { state.updatePosition(position) state.updateMotionEvent(MotionEvent.Move) return when (state.currentShape) { ShapeEnum.Line -> handleLineMove(position) ShapeEnum.Circle -> handleCircleMove(position) ShapeEnum.Triangle -> handleTriangleMove(position) ShapeEnum.Rectangle -> handleRectangleMove(position) ShapeEnum.Polygon -> handlePolygonMove(position) else -> emptyMap() } } /** * 处理鼠标抬起事件 */ fun handleMouseUp(position: Offset, bitmapWidth: Int, bitmapHeight: Int): Pair? { state.updatePosition(position) state.updateMotionEvent(MotionEvent.Up) return when (state.currentShape) { ShapeEnum.Line -> handleLineUp(bitmapWidth, bitmapHeight) ShapeEnum.Circle -> handleCircleUp(bitmapWidth, bitmapHeight) ShapeEnum.Triangle -> handleTriangleUp(bitmapWidth, bitmapHeight) ShapeEnum.Rectangle -> handleRectangleUp(bitmapWidth, bitmapHeight) ShapeEnum.Polygon -> handlePolygonUp(bitmapWidth, bitmapHeight) else -> null } } // ========== 线段处理 ========== private fun handleLineDown(position: Offset) { if (state.previousPosition != position && state.currentLineStart == Offset.Unspecified) { state.updateLineState(start = position) } else if (state.currentLineStart != Offset.Unspecified) { state.updateLineState(end = position) } } private fun handleLineMove(position: Offset): Map { state.updateLineState(end = position) return if (state.currentLineStart != Offset.Unspecified) { mapOf(state.currentLineStart to Line(state.currentLineStart, position, state.currentShapeProperty)) } else emptyMap() } private fun handleLineUp(bitmapWidth: Int, bitmapHeight: Int): Pair? { val startValidation = CoordinateSystem.validateOffset(state.currentLineStart, bitmapWidth, bitmapHeight) val endValidation = CoordinateSystem.validateOffset(state.currentLineEnd, bitmapWidth, bitmapHeight) return if (startValidation.isValid && endValidation.isValid) { val displayLine = Line(state.currentLineStart, state.currentLineEnd, state.currentShapeProperty) val originalLine = coordinateConverter.convertLineToOriginal(displayLine) val lineKey = state.currentLineStart // 保存key,避免在clearCurrentDrawingState后丢失 state.addShape(lineKey, displayLine, originalLine) state.recordLastDrawnShape(lineKey, "Line") logger.info("添加线段: ${state.currentLineStart} -> ${state.currentLineEnd}") // 重置线段状态,准备下次绘制 state.clearCurrentDrawingState() Pair(lineKey, displayLine) } else { logger.warn("线段坐标无效: ${startValidation.message}, ${endValidation.message}") null } } // ========== 圆形处理 ========== private fun handleCircleDown(position: Offset) { if (state.previousPosition != position && state.currentCircleCenter == Offset.Unspecified) { state.updateCircleState(center = position) } } private fun handleCircleMove(position: Offset): Map { val radius = CoordinateSystem.calculateCircleRadius(state.currentCircleCenter, position) state.updateCircleState(radius = radius) return if (state.currentCircleCenter != Offset.Unspecified) { mapOf(state.currentCircleCenter to Circle(state.currentCircleCenter, radius, state.currentShapeProperty)) } else emptyMap() } private fun handleCircleUp(bitmapWidth: Int, bitmapHeight: Int): Pair? { val centerValidation = CoordinateSystem.validateOffset(state.currentCircleCenter, bitmapWidth, bitmapHeight) return if (centerValidation.isValid && state.currentCircleRadius > 0) { val displayCircle = Circle(state.currentCircleCenter, state.currentCircleRadius, state.currentShapeProperty) val originalCircle = coordinateConverter.convertCircleToOriginal(displayCircle) val circleKey = state.currentCircleCenter // 保存key,避免在clearCurrentDrawingState后丢失 state.addShape(circleKey, displayCircle, originalCircle) state.recordLastDrawnShape(circleKey, "Circle") logger.info("添加圆形: 中心=${state.currentCircleCenter}, 半径=${state.currentCircleRadius}") // 重置圆形状态,准备下次绘制 state.clearCurrentDrawingState() Pair(circleKey, displayCircle) } else { logger.warn("圆形坐标无效: ${centerValidation.message}") null } } // ========== 三角形处理 ========== private fun handleTriangleDown(position: Offset) { determineTriangleCoordinates() } private fun handleTriangleMove(position: Offset): Map { determineTriangleCoordinates() return if (state.currentTriangleFirst != Offset.Unspecified && state.currentTriangleSecond != Offset.Unspecified && state.currentTriangleThird != Offset.Unspecified) { val triangle = Triangle(state.currentTriangleFirst, state.currentTriangleSecond, state.currentTriangleThird, state.currentShapeProperty) mapOf(state.currentTriangleFirst to triangle) } else emptyMap() } private fun handleTriangleUp(bitmapWidth: Int, bitmapHeight: Int): Pair? { val firstValidation = CoordinateSystem.validateOffset(state.currentTriangleFirst, bitmapWidth, bitmapHeight) val secondValidation = CoordinateSystem.validateOffset(state.currentTriangleSecond, bitmapWidth, bitmapHeight) val thirdValidation = CoordinateSystem.validateOffset(state.currentTriangleThird, bitmapWidth, bitmapHeight) return if (firstValidation.isValid && secondValidation.isValid && thirdValidation.isValid) { val displayTriangle = Triangle(state.currentTriangleFirst, state.currentTriangleSecond, state.currentTriangleThird, state.currentShapeProperty) val originalTriangle = coordinateConverter.convertTriangleToOriginal(displayTriangle) val triangleKey = state.currentTriangleFirst // 保存key,避免在clearCurrentDrawingState后丢失 state.addShape(triangleKey, displayTriangle, originalTriangle) state.recordLastDrawnShape(triangleKey, "Triangle") logger.info("添加三角形: ${state.currentTriangleFirst}, ${state.currentTriangleSecond}, ${state.currentTriangleThird}") // 重置三角形状态,准备下次绘制 state.clearCurrentDrawingState() Pair(triangleKey, displayTriangle) } else { logger.warn("三角形坐标无效: ${firstValidation.message}, ${secondValidation.message}, ${thirdValidation.message}") null } } private fun determineTriangleCoordinates() { if (state.previousPosition != state.currentPosition && state.currentTriangleFirst == Offset.Unspecified) { state.updateTriangleState(first = state.currentPosition) } else if (state.currentTriangleFirst != Offset.Unspecified && state.currentTriangleSecond == Offset.Unspecified && state.currentTriangleFirst != state.currentPosition) { state.updateTriangleState(second = state.currentPosition) } else if (state.currentTriangleFirst != Offset.Unspecified && state.currentTriangleSecond != Offset.Unspecified && state.currentTriangleThird == Offset.Unspecified && state.currentTriangleSecond != state.currentPosition) { state.updateTriangleState(third = state.currentPosition) } } // ========== 矩形处理 ========== private fun handleRectangleDown(position: Offset) { if (state.previousPosition != position && state.currentRectTL == Offset.Unspecified) { state.updateRectangleState(tl = position, first = position) } else if (state.currentRectTL != Offset.Unspecified) { state.updateRectangleState(br = position) determineRectangleCoordinates() } } private fun handleRectangleMove(position: Offset): Map { state.updateRectangleState(br = position) determineRectangleCoordinates() return if (state.currentRectFirst != Offset.Unspecified) { val rect = Rectangle(state.currentRectTL, state.currentRectBL, state.currentRectBR, state.currentRectTR, state.currentRectFirst, state.currentShapeProperty) mapOf(state.currentRectFirst to rect) } else emptyMap() } private fun handleRectangleUp(bitmapWidth: Int, bitmapHeight: Int): Pair? { val tlValidation = CoordinateSystem.validateOffset(state.currentRectTL, bitmapWidth, bitmapHeight) val brValidation = CoordinateSystem.validateOffset(state.currentRectBR, bitmapWidth, bitmapHeight) return if (tlValidation.isValid && brValidation.isValid) { val displayRect = Rectangle(state.currentRectTL, state.currentRectBL, state.currentRectBR, state.currentRectTR, state.currentRectFirst, state.currentShapeProperty) val originalRect = coordinateConverter.convertRectangleToOriginal(displayRect) val rectKey = state.currentRectFirst // 保存key,避免在clearCurrentDrawingState后丢失 state.addShape(rectKey, displayRect, originalRect) state.recordLastDrawnShape(rectKey, "Rectangle") logger.info("添加矩形: ${state.currentRectTL} -> ${state.currentRectBR}") // 重置矩形状态,准备下次绘制 state.clearCurrentDrawingState() Pair(rectKey, displayRect) } else { logger.warn("矩形坐标无效: ${tlValidation.message}, ${brValidation.message}") null } } private fun determineRectangleCoordinates() { if (state.currentRectBR.x > state.currentRectFirst.x && state.currentRectBR.y > state.currentRectFirst.y) { if (state.currentRectTL != state.currentRectFirst) { state.updateRectangleState(tl = state.currentRectFirst) } state.updateRectangleState( tr = Offset(state.currentRectBR.x, state.currentRectTL.y), bl = Offset(state.currentRectTL.x, state.currentRectBR.y) ) } else if (state.currentRectBR.x > state.currentRectFirst.x && state.currentRectBR.y < state.currentRectFirst.y) { if (state.currentRectTL != state.currentRectFirst) { state.updateRectangleState(tl = state.currentRectFirst) } state.updateRectangleState( bl = state.currentRectTL, tr = state.currentRectBR, tl = Offset(state.currentRectBL.x, state.currentRectTR.y), br = Offset(state.currentRectTR.x, state.currentRectBL.y) ) } else if (state.currentRectBR.x < state.currentRectFirst.x && state.currentRectBR.y > state.currentRectFirst.y) { if (state.currentRectTL != state.currentRectFirst) { state.updateRectangleState(tl = state.currentRectFirst) } state.updateRectangleState( tr = state.currentRectTL, bl = state.currentRectBR, tl = Offset(state.currentRectBL.x, state.currentRectTR.y), br = Offset(state.currentRectTR.x, state.currentRectBL.y) ) } else if (state.currentRectBR.x < state.currentRectFirst.x && state.currentRectBR.y < state.currentRectFirst.y) { if (state.currentRectTL != state.currentRectFirst) { state.updateRectangleState(tl = state.currentRectFirst) } val temp = state.currentRectTL state.updateRectangleState( tl = state.currentRectBR, br = temp, tr = Offset(state.currentRectBR.x, state.currentRectTL.y), bl = Offset(state.currentRectTL.x, state.currentRectBR.y) ) } } // ========== 多边形处理 ========== private fun handlePolygonDown(position: Offset) { if (state.previousPosition != position && state.currentPolygonFirst == Offset.Unspecified) { state.updatePolygonState(first = position, addPoint = position) } else if (state.currentPolygonFirst != Offset.Unspecified) { state.updatePolygonState(addPoint = position) } } private fun handlePolygonMove(position: Offset): Map { state.updatePolygonState(addPoint = position) return if (state.currentPolygonFirst != Offset.Unspecified) { val polygon = Polygon(state.currentPolygonPoints.toList(), state.currentShapeProperty) mapOf(state.currentPolygonFirst to polygon) } else emptyMap() } private fun handlePolygonUp(bitmapWidth: Int, bitmapHeight: Int): Pair? { return if (state.currentPolygonPoints.size >= 3) { val boundaryValidation = CoordinateSystem.validateShapeBoundary(state.currentPolygonPoints.toList(), bitmapWidth, bitmapHeight) if (boundaryValidation.isValid) { val displayPolygon = Polygon(state.currentPolygonPoints.toList(), state.currentShapeProperty) val originalPolygon = coordinateConverter.convertPolygonToOriginal(displayPolygon) val polygonKey = state.currentPolygonFirst // 保存key,避免在clearCurrentDrawingState后丢失 state.addShape(polygonKey, displayPolygon, originalPolygon) state.recordLastDrawnShape(polygonKey, "Polygon") logger.info("添加多边形: ${state.currentPolygonPoints.size}个顶点") // 重置多边形状态,准备下次绘制 state.clearCurrentDrawingState() Pair(polygonKey, displayPolygon) } else { logger.warn("多边形边界无效: ${boundaryValidation.message}") null } } else { logger.warn("多边形顶点数量不足: ${state.currentPolygonPoints.size} < 3") null } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/helper/SpecialLayerHelper.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.helper import androidx.compose.ui.graphics.ImageBitmap import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.ImageLayer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.Layer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.LayerManager import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.LayerType import java.util.UUID /** * 背景层助手类,集中管理特殊图层(如背景层)的逻辑 * 包括缓存管理、查询、创建和更新 */ class SpecialLayerHelper( private val layerManager: LayerManager, private val backgroundLayerName: String = BACKGROUND_LAYER_NAME ) { private var cachedBackgroundLayer: ImageLayer? = null private var cachedBackgroundLayerId: UUID? = null companion object { const val BACKGROUND_LAYER_NAME = "背景图层" } /** * 获取背景层,带缓存机制 */ fun getBackgroundLayer(): ImageLayer? { // 验证缓存是否仍然有效 validateCache() if (cachedBackgroundLayer != null) { return cachedBackgroundLayer } // 缓存失效或不存在,重新查找 val found = findBackgroundLayer() cachedBackgroundLayer = found cachedBackgroundLayerId = found?.id return found } /** * 获取或创建背景层 */ fun getOrCreateBackgroundLayer(image: ImageBitmap): ImageLayer { val existing = getBackgroundLayer() if (existing != null) { return existing } // 创建新的背景层 val newLayer = ImageLayer(backgroundLayerName, image) layerManager.addLayer(newLayer, index = 0) cachedBackgroundLayer = newLayer cachedBackgroundLayerId = newLayer.id return newLayer } /** * 更新背景层图像 */ fun updateBackgroundLayer(image: ImageBitmap) { val layer = getOrCreateBackgroundLayer(image) layer.updateImage(image) } /** * 检查是否存在背景层 */ fun hasBackgroundLayer(): Boolean { return getBackgroundLayer() != null } /** * 移除背景层 */ fun removeBackgroundLayer(): Boolean { val bgLayer = getBackgroundLayer() ?: return false invalidateCache() return layerManager.removeLayer(bgLayer.id) != null } /** * 检查是否为背景层 */ fun isBackgroundLayer(layer: Layer): Boolean { return layer.name == backgroundLayerName && layer.type == LayerType.IMAGE } /** * 验证缓存的有效性 */ private fun validateCache() { if (cachedBackgroundLayer == null || cachedBackgroundLayerId == null) { return } // 检查缓存的图层是否仍在图层管理器中 val stillExists = layerManager.getLayerById(cachedBackgroundLayerId!!) != null if (!stillExists) { invalidateCache() } } /** * 清空缓存 */ private fun invalidateCache() { cachedBackgroundLayer = null cachedBackgroundLayerId = null } /** * 在图层管理器中查找背景层 */ private fun findBackgroundLayer(): ImageLayer? { return layerManager.layers.value .firstOrNull { isBackgroundLayer(it) } as? ImageLayer } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/layer/ImageLayer.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.withTransform import androidx.compose.ui.unit.IntSize import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.EditorController /** * 图像图层,负责持有位图及其变换信息。 */ class ImageLayer( name: String, image: ImageBitmap? = null, transform: LayerTransform = LayerTransform() ) : Layer( type = LayerType.IMAGE, name = name ) { var image by mutableStateOf(image) private set var transform by mutableStateOf(transform) private set fun updateImage(newImage: ImageBitmap?) { if (image != newImage) { image = newImage markDirty() } } fun updateTransform(newTransform: LayerTransform) { if (transform != newTransform) { transform = newTransform markDirty() } } override fun render(drawScope: DrawScope) { render(drawScope, backgroundSize = null) } /** * 渲染图像层 * @param drawScope 绘制作用域 * @param backgroundSize 背景图的实际尺寸(宽、高),如果提供则基于背景图尺寸计算 fitScale,否则基于画布尺寸 */ fun render(drawScope: DrawScope, backgroundSize: Pair?) { val bitmap = image ?: return // 获取画布尺寸 val canvasWidth = drawScope.size.width val canvasHeight = drawScope.size.height // 安全检查:防止除零错误 if (bitmap.width <= 0 || bitmap.height <= 0 || canvasWidth <= 0 || canvasHeight <= 0) { return } // 判断是否为背景图层(使用常量,避免硬编码) val isBackgroundLayer = name == EditorController.BACKGROUND_LAYER_NAME if (isBackgroundLayer) { // 背景图层:填充整个画布绘制区域,不保持宽高比(与涂鸦模块一致) drawScope.drawImage( bitmap, dstSize = IntSize(canvasWidth.toInt(), canvasHeight.toInt()), alpha = opacity ) } else { // 用户添加的图像层:适应背景图或画布并居中显示 // 计算图像缩放比例,保持宽高比,适应背景图或画布(不放大,只缩小) // 如果提供了背景层尺寸,需要考虑背景层在画布上的显示比例 val fitScale = if (backgroundSize != null) { val referenceWidth = backgroundSize.first val referenceHeight = backgroundSize.second // 计算背景层在画布上的缩放比例(背景层被拉伸到画布尺寸) val backgroundScaleX = canvasWidth / referenceWidth val backgroundScaleY = canvasHeight / referenceHeight // 计算基于背景层原始尺寸的缩放比例,确保图像尺寸不超过背景层尺寸 val referenceScaleX = referenceWidth / bitmap.width val referenceScaleY = referenceHeight / bitmap.height val referenceFitScale = minOf(referenceScaleX, referenceScaleY).coerceAtMost(1f) // 图像层在背景层原始坐标系中的缩放比例 // 然后需要乘以背景层在画布上的缩放比例,得到在画布坐标系中的缩放比例 referenceFitScale * minOf(backgroundScaleX, backgroundScaleY) } else { // 没有背景层时,基于画布尺寸 val canvasScaleX = canvasWidth / bitmap.width val canvasScaleY = canvasHeight / bitmap.height minOf(canvasScaleX, canvasScaleY).coerceAtMost(1f) } // 计算缩放后的图像尺寸 val scaledWidth = bitmap.width * fitScale val scaledHeight = bitmap.height * fitScale // 计算居中位置 val centerOffsetX = (canvasWidth - scaledWidth) / 2f val centerOffsetY = (canvasHeight - scaledHeight) / 2f // 适应后图像的中心点(在画布坐标系中) val adaptedImageCenter = Offset( centerOffsetX + scaledWidth / 2f, centerOffsetY + scaledHeight / 2f ) // 计算图像中心点(在图像坐标系中) val imageCenter = Offset(bitmap.width / 2f, bitmap.height / 2f) // 计算 pivot(在图像坐标系中) val pivot = if (transform.pivot == Offset.Zero) { imageCenter } else { imageCenter + transform.pivot } // translation 存储的是相对于图像中心的偏移(在图像原始坐标系中) // 在 withTransform 中,后写的变换先执行,所以实际执行顺序是: // 1. 用户缩放(相对于 pivot,在图像原始坐标系中) // 2. 用户旋转(相对于 pivot,在图像原始坐标系中) // 3. 用户平移(translation,在图像原始坐标系中) // 4. 自动缩放(fitScale)- 将图像坐标系转换到适应后的坐标系 // 5. 自动平移(centerOffset)- 在画布坐标系中 val translation = transform.translation drawScope.withTransform({ // 变换顺序(从外到内,withTransform 的执行顺序): // 注意:withTransform 中后写的变换先执行,所以应该先写自动适应,再写用户变换 // 1. 自动平移(centerOffset)- 最外层,最后执行 translate(centerOffsetX, centerOffsetY) // 2. 自动缩放(fitScale)- 将图像坐标系转换到适应后的坐标系 scale(fitScale, fitScale) // 3. 用户平移(在图像原始坐标系中,相对于图像中心) // 由于 translation 是相对于图像中心的偏移,需要先平移到图像中心,应用偏移,再平移回去 if (translation != Offset.Zero) { translate(imageCenter.x, imageCenter.y) translate(translation.x, translation.y) translate(-imageCenter.x, -imageCenter.y) } // 4. 用户旋转(相对于 pivot,在图像原始坐标系中) if (transform.rotation != 0f) { translate(pivot.x, pivot.y) rotate(transform.rotation) translate(-pivot.x, -pivot.y) } // 5. 用户缩放(相对于 pivot,在图像原始坐标系中)- 最内层,最先执行 if (transform.scaleX != 1f || transform.scaleY != 1f) { translate(pivot.x, pivot.y) scale(transform.scaleX, transform.scaleY) translate(-pivot.x, -pivot.y) } }) { // 应用裁剪区域(如果存在) val cropRect = transform.cropRect if (cropRect != null) { // 裁剪区域是在图像坐标系中定义的 // 由于 withTransform 已经应用了 fitScale,裁剪区域也在当前坐标系中 // 使用 clipPath 来裁剪 val clipPath = Path().apply { addRect(cropRect) } drawScope.clipPath(clipPath) { drawScope.drawImage(bitmap, alpha = opacity) } } else { drawScope.drawImage(bitmap, alpha = opacity) } } } } } /** * 图层变换信息。 * * @param translation 相对于图像中心的偏移(在图像原始坐标系中) * @param scaleX X轴缩放比例 * @param scaleY Y轴缩放比例 * @param rotation 旋转角度(度) * @param pivot 旋转和缩放的枢轴点(在图像坐标系中,相对于图像中心) * @param cropRect 裁剪区域(在图像坐标系中,null表示不裁剪) */ data class LayerTransform( val translation: Offset = Offset.Zero, val scaleX: Float = 1f, val scaleY: Float = 1f, val rotation: Float = 0f, val pivot: Offset = Offset.Zero, val cropRect: Rect? = null ) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/layer/Layer.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.drawscope.DrawScope import java.util.UUID /** * 图层类型枚举 * * 目前仅支持图像层与形状层,未来可扩展到调整层、文本层等。 */ enum class LayerType { IMAGE, SHAPE } /** * Layer 抽象基类,封装所有图层的公共属性与生命周期。 * * @property type 图层类型 * @property id 图层唯一标识 * @property name 图层名称 * @property visible 是否可见 * @property opacity 透明度,范围 0f..1f * @property locked 是否锁定(锁定后禁止编辑/变换) */ @Stable abstract class Layer( val type: LayerType, val id: UUID = UUID.randomUUID(), name: String, visible: Boolean = true, opacity: Float = 1f, locked: Boolean = false ) { var name by mutableStateOf(name) private set var visible by mutableStateOf(visible) private set var opacity by mutableStateOf(opacity.coerceIn(0f, 1f)) private set var locked by mutableStateOf(locked) private set /** * 图层版本号,用于标记图层变化,优化渲染缓存 * 当图层属性变化时,版本号递增,触发缓存失效 * * 注意:使用普通变量而非 State,避免在 Compose 中读取时触发不必要的重组 * 版本号主要用于标记变化,不直接参与 Compose 状态管理 */ private var _version = 0L val version: Long get() = _version /** * 标记图层为脏状态,递增版本号 * 子类在属性变化时应调用此方法 */ protected fun markDirty() { _version++ } /** * 重命名当前图层。 */ fun rename(newName: String) { if (name != newName) { name = newName markDirty() } } /** * 切换图层可见性。 */ fun setVisibility(isVisible: Boolean) { if (visible != isVisible) { visible = isVisible markDirty() } } /** * 更新透明度,范围自动限制在 0f..1f。 */ fun updateOpacity(alpha: Float) { val newOpacity = alpha.coerceIn(0f, 1f) if (opacity != newOpacity) { opacity = newOpacity markDirty() } } /** * 切换锁定状态。 */ fun updateLocked(isLocked: Boolean) { if (locked != isLocked) { locked = isLocked markDirty() } } /** * 当图层被加入 LayerManager 时回调。 */ open fun onAttach() {} /** * 当图层被移出 LayerManager 时回调。 */ open fun onDetach() {} /** * 将当前图层绘制到指定的 [DrawScope] 中。 * * 默认实现为空,由具体图层自行实现。 */ open fun render(drawScope: DrawScope) = Unit } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/layer/LayerManager.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.UUID import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock /** * 负责管理图层列表的核心类,提供增删改查、排序以及事件监听能力。 */ class LayerManager { private val lock = ReentrantLock() private val _layers = MutableStateFlow>(emptyList()) val layers: StateFlow> = _layers.asStateFlow() private val _activeLayer = MutableStateFlow(null) val activeLayer: StateFlow = _activeLayer.asStateFlow() private val layerObservers = CopyOnWriteArraySet() private val activeLayerObservers = CopyOnWriteArraySet() /** * 添加图层。默认追加到末尾,可通过 [index] 指定插入位置。 */ fun addLayer(layer: Layer, index: Int? = null) { lock.withLock { val mutable = _layers.value.toMutableList() val insertIndex = index?.coerceIn(0, mutable.size) ?: mutable.size mutable.add(insertIndex, layer) _layers.value = mutable.toList() _activeLayer.value = layer } layer.onAttach() notifyLayerObservers() notifyActiveLayerObservers() } /** * 根据 [layerId] 移除图层。 * @return 被移除的图层实例,若未找到则返回 null。 */ fun removeLayer(layerId: UUID): Layer? { var removed: Layer? = null lock.withLock { val mutable = _layers.value.toMutableList() val index = mutable.indexOfFirst { it.id == layerId } if (index != -1) { removed = mutable.removeAt(index) _layers.value = mutable.toList() if (_activeLayer.value?.id == layerId) { _activeLayer.value = mutable.lastOrNull() } } } removed?.onDetach() if (removed != null) { notifyLayerObservers() notifyActiveLayerObservers() } return removed } /** * 清空所有图层。 */ fun clear() { val detached: List lock.withLock { detached = _layers.value _layers.value = emptyList() _activeLayer.value = null } detached.forEach { it.onDetach() } notifyLayerObservers() notifyActiveLayerObservers() } /** * 将图层上移一层。 */ fun moveLayerUp(layerId: UUID): Boolean = moveByOffset(layerId, 1) /** * 将图层下移一层。 */ fun moveLayerDown(layerId: UUID): Boolean = moveByOffset(layerId, -1) /** * 将图层移动到指定位置。 */ fun moveLayerTo(layerId: UUID, index: Int): Boolean { var moved = false lock.withLock { val mutable = _layers.value.toMutableList() val currentIndex = mutable.indexOfFirst { it.id == layerId } if (currentIndex != -1) { val targetIndex = index.coerceIn(0, mutable.lastIndex.coerceAtLeast(0)) if (currentIndex != targetIndex) { val layer = mutable.removeAt(currentIndex) mutable.add(targetIndex, layer) _layers.value = mutable.toList() moved = true } } } if (moved) notifyLayerObservers() return moved } /** * 设置当前激活的图层。 */ fun setActiveLayer(layerId: UUID?) { var shouldNotify = false lock.withLock { val target = layerId?.let { id -> _layers.value.firstOrNull { it.id == id } } if (_activeLayer.value !== target) { _activeLayer.value = target shouldNotify = true } } if (shouldNotify) notifyActiveLayerObservers() } /** * 重命名图层。 */ fun renameLayer(layerId: UUID, newName: String): Boolean = updateLayer(layerId, { it.name != newName }) { it.rename(newName) } /** * 更新图层可见性。 */ fun setLayerVisibility(layerId: UUID, visible: Boolean): Boolean = updateLayer(layerId, { it.visible != visible }) { it.setVisibility(visible) } /** * 更新图层透明度。 */ fun setLayerOpacity(layerId: UUID, opacity: Float): Boolean { val targetOpacity = opacity.coerceIn(0f, 1f) return updateLayer(layerId, { it.opacity != targetOpacity }) { it.updateOpacity(targetOpacity) } } /** * 更新图层锁定状态。 */ fun setLayerLocked(layerId: UUID, locked: Boolean): Boolean = updateLayer(layerId, { it.locked != locked }) { it.updateLocked(locked) } /** * 根据 ID 获取图层。 */ fun getLayerById(layerId: UUID): Layer? = lock.withLock { _layers.value.firstOrNull { it.id == layerId } } /** * 批量替换当前图层列表,并指定激活层。 */ fun replaceLayers(layers: List, activeLayerId: UUID? = null) { val previous = lock.withLock { val snapshot = _layers.value _layers.value = layers.toList() _activeLayer.value = activeLayerId?.let { id -> layers.firstOrNull { it.id == id } ?: layers.lastOrNull() } snapshot } previous.forEach { it.onDetach() } layers.forEach { it.onAttach() } notifyLayerObservers() notifyActiveLayerObservers() } /** * 注册图层列表监听器,返回用于移除监听的函数。 */ fun addLayerObserver(observer: LayerListObserver, notifyImmediately: Boolean = true): () -> Unit { layerObservers.add(observer) if (notifyImmediately) { observer.onLayersChanged(_layers.value) } return { layerObservers.remove(observer) } } /** * 注册激活图层监听器,返回用于移除监听的函数。 */ fun addActiveLayerObserver(observer: ActiveLayerObserver, notifyImmediately: Boolean = true): () -> Unit { activeLayerObservers.add(observer) if (notifyImmediately) { observer.onActiveLayerChanged(_activeLayer.value) } return { activeLayerObservers.remove(observer) } } /** * 通用的图层属性更新方法,提取了重复的模式 * @param layerId 图层ID * @param shouldUpdate 判断是否需要更新的谓词 * @param update 执行更新的操作 */ private fun updateLayer( layerId: UUID, shouldUpdate: (Layer) -> Boolean, update: (Layer) -> Unit ): Boolean { var updated = false lock.withLock { val layer = _layers.value.firstOrNull { it.id == layerId } if (layer != null && shouldUpdate(layer)) { update(layer) updated = true } } if (updated) notifyLayerObservers() return updated } private fun moveByOffset(layerId: UUID, offset: Int): Boolean { var moved = false lock.withLock { if (offset == 0) return false val mutable = _layers.value.toMutableList() val currentIndex = mutable.indexOfFirst { it.id == layerId } if (currentIndex != -1 && mutable.isNotEmpty()) { val targetIndex = (currentIndex + offset).coerceIn(0, mutable.lastIndex) if (currentIndex != targetIndex) { val layer = mutable.removeAt(currentIndex) mutable.add(targetIndex, layer) _layers.value = mutable.toList() moved = true } } } if (moved) notifyLayerObservers() return moved } private fun notifyLayerObservers() { if (layerObservers.isEmpty()) return val snapshot = _layers.value layerObservers.forEach { it.onLayersChanged(snapshot) } } private fun notifyActiveLayerObservers() { if (activeLayerObservers.isEmpty()) return val active = _activeLayer.value activeLayerObservers.forEach { it.onActiveLayerChanged(active) } } } fun interface LayerListObserver { fun onLayersChanged(layers: List) } fun interface ActiveLayerObserver { fun onActiveLayerChanged(activeLayer: Layer?) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/layer/LayerRenderer.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.ShapeDrawingViewModel import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.geometry.CanvasDrawer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.TextDrawer import java.util.UUID /** * 负责任务图层绘制与合成的渲染器。 * * 使用图层版本号优化渲染,只有版本变化的图层才会重新渲染。 */ class LayerRenderer( private val layerManager: LayerManager ) { private val opacityPaint = Paint().apply { isAntiAlias = true } private val shapeRenderer by lazy { ShapeDrawingViewModel() } /** * 将当前 LayerManager 中的图层全部绘制到给定的 [DrawScope]。 */ fun drawAll(drawScope: DrawScope) { drawAll(drawScope, layerManager.layers.value) } /** * 将指定的图层列表绘制到给定的 [DrawScope]。 * * 注意:由于 DrawScope 的限制,无法在此层面实现真正的缓存优化。 * 图层的 version 字段已准备好,可用于将来在 Compose 层面通过 * remember、key() 和 Modifier.drawWithCache 实现真正的缓存优化。 */ fun drawAll(drawScope: DrawScope, layers: List) { if (layers.isEmpty()) return layers.forEach { layer -> if (!layer.visible || layer.opacity <= 0f) return@forEach drawLayer(drawScope, layer) } } /** * 绘制单个图层 * * 注意:此方法应该是 internal 或 public,以便在 Compose 层面使用 */ fun drawLayer(drawScope: DrawScope, layer: Layer) { drawScope.drawIntoCanvas { canvas -> val bounds = Rect(Offset.Zero, drawScope.size) opacityPaint.alpha = layer.opacity.coerceIn(0f, 1f) canvas.saveLayer(bounds, opacityPaint) try { when (layer) { is ImageLayer -> { // 获取背景图尺寸(如果存在) val backgroundLayer = layerManager.layers.value .firstOrNull { it.name == cn.netdiscovery.monica.ui.controlpanel.shapedrawing.EditorController.BACKGROUND_LAYER_NAME && it is ImageLayer } as? ImageLayer val backgroundSize = backgroundLayer?.image?.let { Pair(it.width.toFloat(), it.height.toFloat()) } layer.render(drawScope, backgroundSize) } is ShapeLayer -> drawShapeLayer(drawScope, layer) else -> layer.render(drawScope) } } finally { canvas.restore() } } } private fun drawShapeLayer(drawScope: DrawScope, shapeLayer: ShapeLayer) { if (shapeLayer.isEmpty()) return val canvasDrawer = CanvasDrawer(TextDrawer, drawScope.drawContext.canvas) shapeRenderer.drawShape( canvasDrawer = canvasDrawer, lines = shapeLayer.displayLines, circles = shapeLayer.displayCircles, triangles = shapeLayer.displayTriangles, rectangles = shapeLayer.displayRectangles, polygons = shapeLayer.displayPolygons, texts = shapeLayer.displayTexts, saveFlag = false ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/layer/ShapeLayer.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.drawscope.DrawScope import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape /** * 形状图层,负责维护各种几何形状的数据集合。 * * 渲染逻辑会在后续的 LayerRenderer 中统一处理,此处只负责数据管理。 */ class ShapeLayer( name: String ) : Layer( type = LayerType.SHAPE, name = name ) { val displayLines: SnapshotStateMap = mutableStateMapOf() val originalLines: SnapshotStateMap = mutableStateMapOf() val displayCircles: SnapshotStateMap = mutableStateMapOf() val originalCircles: SnapshotStateMap = mutableStateMapOf() val displayTriangles: SnapshotStateMap = mutableStateMapOf() val originalTriangles: SnapshotStateMap = mutableStateMapOf() val displayRectangles: SnapshotStateMap = mutableStateMapOf() val originalRectangles: SnapshotStateMap = mutableStateMapOf() val displayPolygons: SnapshotStateMap = mutableStateMapOf() val originalPolygons: SnapshotStateMap = mutableStateMapOf() val displayTexts: SnapshotStateMap = mutableStateMapOf() val originalTexts: SnapshotStateMap = mutableStateMapOf() override fun render(drawScope: DrawScope) = Unit fun addShape(key: Offset, displayShape: Shape, originalShape: Shape) { when (displayShape) { is Shape.Line -> { displayLines[key] = displayShape originalLines[key] = (originalShape as? Shape.Line) ?: displayShape } is Shape.Circle -> { displayCircles[key] = displayShape originalCircles[key] = (originalShape as? Shape.Circle) ?: displayShape } is Shape.Triangle -> { displayTriangles[key] = displayShape originalTriangles[key] = (originalShape as? Shape.Triangle) ?: displayShape } is Shape.Rectangle -> { displayRectangles[key] = displayShape originalRectangles[key] = (originalShape as? Shape.Rectangle) ?: displayShape } is Shape.Polygon -> { displayPolygons[key] = displayShape originalPolygons[key] = (originalShape as? Shape.Polygon) ?: displayShape } is Shape.Text -> { displayTexts[key] = displayShape originalTexts[key] = (originalShape as? Shape.Text) ?: displayShape } } } fun removeShape(key: Offset) { displayLines.remove(key) originalLines.remove(key) displayCircles.remove(key) originalCircles.remove(key) displayTriangles.remove(key) originalTriangles.remove(key) displayRectangles.remove(key) originalRectangles.remove(key) displayPolygons.remove(key) originalPolygons.remove(key) displayTexts.remove(key) originalTexts.remove(key) } fun clearShapes() { displayLines.clear() originalLines.clear() displayCircles.clear() originalCircles.clear() displayTriangles.clear() originalTriangles.clear() displayRectangles.clear() originalRectangles.clear() displayPolygons.clear() originalPolygons.clear() displayTexts.clear() originalTexts.clear() } fun isEmpty(): Boolean = displayLines.isEmpty() && displayCircles.isEmpty() && displayTriangles.isEmpty() && displayRectangles.isEmpty() && displayPolygons.isEmpty() && displayTexts.isEmpty() fun snapshot(): ShapeLayerSnapshot = ShapeLayerSnapshot( displayLines = displayLines.toMap(), originalLines = originalLines.toMap(), displayCircles = displayCircles.toMap(), originalCircles = originalCircles.toMap(), displayTriangles = displayTriangles.toMap(), originalTriangles = originalTriangles.toMap(), displayRectangles = displayRectangles.toMap(), originalRectangles = originalRectangles.toMap(), displayPolygons = displayPolygons.toMap(), originalPolygons = originalPolygons.toMap(), displayTexts = displayTexts.toMap(), originalTexts = originalTexts.toMap() ) fun restore(snapshot: ShapeLayerSnapshot) { replaceAll( snapshot.displayLines, snapshot.originalLines, snapshot.displayCircles, snapshot.originalCircles, snapshot.displayTriangles, snapshot.originalTriangles, snapshot.displayRectangles, snapshot.originalRectangles, snapshot.displayPolygons, snapshot.originalPolygons, snapshot.displayTexts, snapshot.originalTexts ) } fun replaceAll( displayLines: Map, originalLines: Map, displayCircles: Map, originalCircles: Map, displayTriangles: Map, originalTriangles: Map, displayRectangles: Map, originalRectangles: Map, displayPolygons: Map, originalPolygons: Map, displayTexts: Map, originalTexts: Map ) { this.displayLines.clear() this.displayLines.putAll(displayLines) this.originalLines.clear() this.originalLines.putAll(originalLines) this.displayCircles.clear() this.displayCircles.putAll(displayCircles) this.originalCircles.clear() this.originalCircles.putAll(originalCircles) this.displayTriangles.clear() this.displayTriangles.putAll(displayTriangles) this.originalTriangles.clear() this.originalTriangles.putAll(originalTriangles) this.displayRectangles.clear() this.displayRectangles.putAll(displayRectangles) this.originalRectangles.clear() this.originalRectangles.putAll(originalRectangles) this.displayPolygons.clear() this.displayPolygons.putAll(displayPolygons) this.originalPolygons.clear() this.originalPolygons.putAll(originalPolygons) this.displayTexts.clear() this.displayTexts.putAll(displayTexts) this.originalTexts.clear() this.originalTexts.putAll(originalTexts) markDirty() } data class ShapeLayerSnapshot( val displayLines: Map, val originalLines: Map, val displayCircles: Map, val originalCircles: Map, val displayTriangles: Map, val originalTriangles: Map, val displayRectangles: Map, val originalRectangles: Map, val displayPolygons: Map, val originalPolygons: Map, val displayTexts: Map, val originalTexts: Map ) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/layer/SpecialLayerHelper.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer import androidx.compose.ui.graphics.ImageBitmap import java.util.UUID /** * 专门处理特殊图层(如背景层)的辅助类 * 集中管理背景层的查找、创建、更新等操作 */ class SpecialLayerHelper( private val layerManager: LayerManager, private val backgroundLayerName: String = "背景图层" ) { private var cachedBackgroundLayer: ImageLayer? = null private var cachedBackgroundLayerId: UUID? = null /** * 获取背景层,如果不存在则返回 null * 使用缓存机制优化性能,自动验证缓存有效性 */ fun getBackgroundLayer(): ImageLayer? { // 先验证缓存是否有效 val cached = validateBackgroundLayerCache() if (cached != null) { return cached } // 缓存失效或不存在,重新查找 val found = layerManager.layers.value .firstOrNull { it.name == backgroundLayerName && it is ImageLayer } as? ImageLayer cachedBackgroundLayer = found cachedBackgroundLayerId = found?.id return found } /** * 获取或创建背景层 * 如果不存在则创建新的背景层并添加到索引 0 */ fun getOrCreateBackgroundLayer(image: ImageBitmap): ImageLayer { val existing = getBackgroundLayer() if (existing != null) { return existing } val newLayer = ImageLayer(backgroundLayerName, image) layerManager.addLayer(newLayer, index = 0) cachedBackgroundLayer = newLayer cachedBackgroundLayerId = newLayer.id return newLayer } /** * 更新背景层图像 * 如果背景层不存在,则创建新的 */ fun updateBackgroundLayer(image: ImageBitmap) { val layer = getOrCreateBackgroundLayer(image) layer.updateImage(image) } /** * 检查是否存在背景层 */ fun hasBackgroundLayer(): Boolean { return getBackgroundLayer() != null } /** * 移除背景层(谨慎使用,通常不应该删除背景层) * 此方法主要用于清理或重置场景 */ fun removeBackgroundLayer(): Boolean { val backgroundLayer = getBackgroundLayer() return if (backgroundLayer != null) { layerManager.removeLayer(backgroundLayer.id) != null } else { false } } /** * 获取背景层的尺寸(如果存在) */ fun getBackgroundSize(): Pair? { return getBackgroundLayer()?.image?.let { Pair(it.width.toFloat(), it.height.toFloat()) } } /** * 验证背景层缓存是否仍然有效 */ private fun validateBackgroundLayerCache(): ImageLayer? { val cachedId = cachedBackgroundLayerId ?: return null val cached = cachedBackgroundLayer // 检查缓存的背景层是否仍在图层列表中 if (cached != null && cached.id == cachedId && layerManager.layers.value.any { it.id == cachedId }) { return cached } // 缓存失效,清空缓存 cachedBackgroundLayer = null cachedBackgroundLayerId = null return null } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/model/Shape.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model import androidx.compose.ui.geometry.Offset import kotlin.math.sqrt import kotlin.math.pow import kotlin.math.PI /** * 形状枚举类型 * * @author Tony Shen * @date 2024/11/22 14:34 * @version V1.0 */ enum class ShapeEnum { Point, Line, Circle, Triangle, Rectangle, Polygon, Text, NotAShape } /** * 形状基类密封类 * 定义了所有支持的几何形状类型 */ sealed class Shape { /** * 线条形状 * @param from 起始点 * @param to 结束点 * @param shapeProperties 形状属性 */ data class Line( val from: Offset, val to: Offset, val shapeProperties: ShapeProperties ) : Shape() { /** * 检查线条是否有效 */ fun isValid(): Boolean = from != Offset.Unspecified && to != Offset.Unspecified /** * 获取线条长度 */ fun getLength(): Float = if (isValid()) { sqrt((to.x - from.x).pow(2) + (to.y - from.y).pow(2)) } else 0f } /** * 圆形形状 * @param center 圆心 * @param radius 半径 * @param shapeProperties 形状属性 */ data class Circle( val center: Offset, val radius: Float, val shapeProperties: ShapeProperties ) : Shape() { /** * 检查圆形是否有效 */ fun isValid(): Boolean = center != Offset.Unspecified && radius > 0 /** * 获取圆形面积 */ fun getArea(): Float = if (isValid()) { (PI * radius * radius).toFloat() } else 0f } /** * 三角形形状 * @param first 第一个顶点 * @param second 第二个顶点 * @param third 第三个顶点 * @param shapeProperties 形状属性 */ data class Triangle( val first: Offset, val second: Offset? = null, val third: Offset? = null, val shapeProperties: ShapeProperties ) : Shape() { /** * 检查三角形是否有效 */ fun isValid(): Boolean = first != Offset.Unspecified && second != Offset.Unspecified && third != Offset.Unspecified /** * 获取三角形顶点列表 */ fun getPoints(): List = listOfNotNull(first, second, third) } /** * 矩形形状 * @param tl 左上角 * @param bl 左下角 * @param br 右下角 * @param tr 右上角 * @param rectFirst 第一个点(用于预览) * @param shapeProperties 形状属性 */ data class Rectangle( val tl: Offset, val bl: Offset, val br: Offset, val tr: Offset, val rectFirst: Offset, val shapeProperties: ShapeProperties ) : Shape() { /** * 检查矩形是否有效 */ fun isValid(): Boolean = tl != Offset.Unspecified && bl != Offset.Unspecified && br != Offset.Unspecified && tr != Offset.Unspecified /** * 获取矩形顶点列表 */ fun getPoints(): List = listOf(tl, bl, br, tr) /** * 获取矩形宽度 */ fun getWidth(): Float = if (isValid()) { sqrt((tr.x - tl.x).pow(2) + (tr.y - tl.y).pow(2)) } else 0f /** * 获取矩形高度 */ fun getHeight(): Float = if (isValid()) { sqrt((bl.x - tl.x).pow(2) + (bl.y - tl.y).pow(2)) } else 0f } /** * 多边形形状 * @param points 顶点列表 * @param shapeProperties 形状属性 */ data class Polygon( val points: List, val shapeProperties: ShapeProperties ) : Shape() { /** * 检查多边形是否有效 */ fun isValid(): Boolean = points.size >= 3 && points.all { it != Offset.Unspecified } /** * 获取多边形的边数 */ fun getSideCount(): Int = points.size } /** * 文本形状 * @param point 文本位置 * @param message 文本内容 * @param shapeProperties 形状属性 */ data class Text( val point: Offset, val message: String, val shapeProperties: ShapeProperties ) : Shape() { /** * 检查文本是否有效 */ fun isValid(): Boolean = point != Offset.Unspecified && message.isNotBlank() } } /** * 扩展函数:获取形状类型 */ fun Shape.getType(): ShapeEnum = when (this) { is Shape.Line -> ShapeEnum.Line is Shape.Circle -> ShapeEnum.Circle is Shape.Triangle -> ShapeEnum.Triangle is Shape.Rectangle -> ShapeEnum.Rectangle is Shape.Polygon -> ShapeEnum.Polygon is Shape.Text -> ShapeEnum.Text } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/model/ShapeProperties.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model import androidx.compose.ui.graphics.Color import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.geometry.Border /** * 形状属性类 * 定义了形状的视觉属性,如颜色、透明度、字体大小等 * * @author Tony Shen * @date 2024/11/24 14:18 * @version V1.0 */ data class ShapeProperties( val color: Color = Color.Red, val alpha: Float = 1f, val fontSize: Float = 40f, val fill: Boolean = false, val border: Border = Border.Line ) { init { require(alpha in 0f..1f) { "Alpha must be between 0.0 and 1.0" } require(fontSize > 0f) { "Font size must be positive" } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/state/ShapeDrawingState.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.state import androidx.compose.runtime.* import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape.* import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.ShapeEnum import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.ShapeProperties import cn.netdiscovery.monica.ui.widget.image.gesture.MotionEvent /** * 形状绘制状态管理 * 统一管理所有形状的状态,降低耦合度 * * @author Tony Shen * @date 2025/9/8 * @version V1.0 */ @Stable class ShapeDrawingState { // 当前选择的形状类型 var currentShape by mutableStateOf(ShapeEnum.NotAShape) private set // 当前形状属性 var currentShapeProperty by mutableStateOf(ShapeProperties()) private set // 当前鼠标位置 var currentPosition by mutableStateOf(Offset.Unspecified) private set var previousPosition by mutableStateOf(Offset.Unspecified) private set // 当前运动事件 var motionEvent by mutableStateOf(MotionEvent.Idle) private set // 线段相关状态 var currentLineStart by mutableStateOf(Offset.Unspecified) private set var currentLineEnd by mutableStateOf(Offset.Unspecified) private set // 圆形相关状态 var currentCircleCenter by mutableStateOf(Offset.Unspecified) private set var currentCircleRadius by mutableStateOf(0.0f) private set // 三角形相关状态 var currentTriangleFirst by mutableStateOf(Offset.Unspecified) private set var currentTriangleSecond by mutableStateOf(Offset.Unspecified) private set var currentTriangleThird by mutableStateOf(Offset.Unspecified) private set // 矩形相关状态 var currentRectFirst by mutableStateOf(Offset.Unspecified) private set var currentRectTL by mutableStateOf(Offset.Unspecified) private set var currentRectBR by mutableStateOf(Offset.Unspecified) private set var currentRectTR by mutableStateOf(Offset.Unspecified) private set var currentRectBL by mutableStateOf(Offset.Unspecified) private set // 多边形相关状态 var currentPolygonFirst by mutableStateOf(Offset.Unspecified) private set var currentPolygonPoints = mutableStateListOf() // 文字相关状态 var currentText by mutableStateOf("") private set // 已完成的形状集合 val displayLines = mutableStateMapOf() val originalLines = mutableStateMapOf() val displayCircles = mutableStateMapOf() val originalCircles = mutableStateMapOf() val displayTriangles = mutableStateMapOf() val originalTriangles = mutableStateMapOf() val displayRectangles = mutableStateMapOf() val originalRectangles = mutableStateMapOf() val displayPolygons = mutableStateMapOf() val originalPolygons = mutableStateMapOf() val displayTexts = mutableStateMapOf() val originalTexts = mutableStateMapOf() // 最后一个绘制的形状跟踪 var lastDrawnShapeKey by mutableStateOf(null) private set var lastDrawnShapeType by mutableStateOf(null) private set /** * 设置当前形状类型 */ fun selectShape(shape: ShapeEnum) { currentShape = shape clearCurrentDrawingState() } /** * 更新形状属性 */ fun updateShapeProperty(property: ShapeProperties) { currentShapeProperty = property } /** * 更新颜色 */ fun updateColor(color: Color) { currentShapeProperty = currentShapeProperty.copy(color = color) } /** * 更新位置信息 */ fun updatePosition(position: Offset) { previousPosition = currentPosition currentPosition = position } /** * 更新运动事件 */ fun updateMotionEvent(event: MotionEvent) { motionEvent = event } /** * 更新线段状态 */ fun updateLineState(start: Offset? = null, end: Offset? = null) { start?.let { currentLineStart = it } end?.let { currentLineEnd = it } } /** * 更新圆形状态 */ fun updateCircleState(center: Offset? = null, radius: Float? = null) { center?.let { currentCircleCenter = it } radius?.let { currentCircleRadius = it } } /** * 更新三角形状态 */ fun updateTriangleState(first: Offset? = null, second: Offset? = null, third: Offset? = null) { first?.let { currentTriangleFirst = it } second?.let { currentTriangleSecond = it } third?.let { currentTriangleThird = it } } /** * 更新矩形状态 */ fun updateRectangleState( first: Offset? = null, tl: Offset? = null, br: Offset? = null, tr: Offset? = null, bl: Offset? = null ) { first?.let { currentRectFirst = it } tl?.let { currentRectTL = it } br?.let { currentRectBR = it } tr?.let { currentRectTR = it } bl?.let { currentRectBL = it } } /** * 更新多边形状态 */ fun updatePolygonState(first: Offset? = null, addPoint: Offset? = null) { first?.let { currentPolygonFirst = it } addPoint?.let { currentPolygonPoints.add(it) } } /** * 更新文字状态 */ fun updateTextState(text: String) { currentText = text } /** * 记录最后绘制的形状 */ fun recordLastDrawnShape(key: Offset, type: String) { lastDrawnShapeKey = key lastDrawnShapeType = type } /** * 清除当前绘制状态(保留已完成的形状) */ fun clearCurrentDrawingState() { // 保存当前颜色设置 val currentColor = currentShapeProperty.color // 清除临时绘制状态 currentLineStart = Offset.Unspecified currentLineEnd = Offset.Unspecified currentCircleCenter = Offset.Unspecified currentCircleRadius = 0.0f currentTriangleFirst = Offset.Unspecified currentTriangleSecond = Offset.Unspecified currentTriangleThird = Offset.Unspecified currentRectFirst = Offset.Unspecified currentRectTL = Offset.Unspecified currentRectBR = Offset.Unspecified currentRectTR = Offset.Unspecified currentRectBL = Offset.Unspecified currentPolygonFirst = Offset.Unspecified currentPolygonPoints.clear() currentText = "" // 重置最后一个形状的跟踪 lastDrawnShapeKey = null lastDrawnShapeType = null // 保持颜色设置 currentShapeProperty = currentShapeProperty.copy(color = currentColor) } /** * 清除所有已完成的形状 */ fun clearAllShapes() { // 清理已完成的形状 displayLines.clear() originalLines.clear() displayCircles.clear() originalCircles.clear() displayTriangles.clear() originalTriangles.clear() displayRectangles.clear() originalRectangles.clear() displayPolygons.clear() originalPolygons.clear() displayTexts.clear() originalTexts.clear() // 清理当前绘制状态 clearCurrentDrawingState() // 重置最后一个形状的跟踪 lastDrawnShapeKey = null lastDrawnShapeType = null } /** * 添加形状到显示和原始集合 */ fun addShape(key: Offset, displayShape: Shape, originalShape: Shape) { when (displayShape) { is Line -> { displayLines[key] = displayShape originalLines[key] = originalShape as Line } is Circle -> { displayCircles[key] = displayShape originalCircles[key] = originalShape as Circle } is Triangle -> { displayTriangles[key] = displayShape originalTriangles[key] = originalShape as Triangle } is Rectangle -> { displayRectangles[key] = displayShape originalRectangles[key] = originalShape as Rectangle } is Polygon -> { displayPolygons[key] = displayShape originalPolygons[key] = originalShape as Polygon } is Text -> { displayTexts[key] = displayShape originalTexts[key] = originalShape as Text } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/widget/CanvasView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.EditorController import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.animation.ShapeAnimationManager import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.ImageLayer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.Layer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.ShapeLayer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.Shape import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.state.ShapeDrawingState import kotlin.math.PI import kotlin.math.sin @Composable fun CanvasView( editorController: EditorController, drawingState: ShapeDrawingState, animationManager: ShapeAnimationManager, modifier: Modifier = Modifier.Companion, overlay: DrawScope.() -> Unit = {}, showImageLayerControls: Boolean = true ) { // 观察图层列表变化,触发重组和重绘 val layers by editorController.layerManager.layers.collectAsState() val activeLayer by editorController.layerManager.activeLayer.collectAsState() Box(modifier = modifier) { // 为每个图层创建独立的缓存层 // 使用 key() 确保只有版本变化的图层才重组 layers.forEach { layer -> if (!layer.visible || layer.opacity <= 0f) return@forEach key(layer.id, layer.version) { // 使用 drawWithCache 缓存图层绘制内容 Canvas( modifier = Modifier .fillMaxSize() .drawWithCache { // 缓存图层绘制内容 // 注意:layer 在 lambda 中被捕获,但由于使用了 key(),只有版本变化时才会重新创建 onDrawBehind { // 绘制单个图层 editorController.layerRenderer.drawLayer(this, layer) } }, onDraw = { // 空的绘制函数,实际绘制在 drawWithCache 的 onDrawBehind 中 } ) } } // 绘制动画和覆盖层(这些不需要缓存,因为它们是动态的) Canvas(modifier = Modifier.fillMaxSize()) { drawAllAnimations( animationManager = animationManager, displayLines = drawingState.displayLines, displayCircles = drawingState.displayCircles, displayTriangles = drawingState.displayTriangles, displayRectangles = drawingState.displayRectangles, displayPolygons = drawingState.displayPolygons ) // 绘制激活图像层的控制点 if (showImageLayerControls) { val activeImageLayer = activeLayer as? ImageLayer if (activeImageLayer != null && !activeImageLayer.locked && !editorController.isBackgroundLayer(activeImageLayer)) { // 获取背景图尺寸(如果存在) val backgroundSize = editorController.getBackgroundSize() ImageLayerControlRenderer.drawControls( drawScope = this, layer = activeImageLayer, canvasWidth = size.width, canvasHeight = size.height, backgroundSize = backgroundSize ) } } overlay() } } } private fun DrawScope.drawAllAnimations( animationManager: ShapeAnimationManager, displayLines: Map, displayCircles: Map, displayTriangles: Map, displayRectangles: Map, displayPolygons: Map ) { val currentTime = System.currentTimeMillis() animationManager.animatedShapes.forEach { (_, animatedShape) -> val elapsed = currentTime - animatedShape.startTime val progress = (elapsed.toFloat() / animatedShape.duration.toFloat()).coerceIn(0f, 1f) if (progress < 1f) { val easedProgress = animationManager.easeInOutCubic(progress) val scale = animationManager.lerp(animatedShape.startScale, animatedShape.endScale, easedProgress) val alpha = animationManager.lerp(animatedShape.startAlpha, animatedShape.endAlpha, easedProgress) val key = animatedShape.key val highlightColor = animatedShape.highlightColor val parts = key.split("_") if (parts.size >= 3) { val x = parts[1].toFloatOrNull() ?: 0f val y = parts[2].toFloatOrNull() ?: 0f val center = Offset(x, y) val pulseAlpha = alpha * (0.5f + 0.5f * sin((progress * PI * 4).toDouble()).toFloat()) drawCircle( color = highlightColor.copy(alpha = pulseAlpha * 0.3f), radius = 30f * scale, center = center ) drawCircle( color = Color.Companion.White.copy(alpha = pulseAlpha * 0.6f), radius = 20f * scale, center = center ) drawCircle( color = highlightColor.copy(alpha = pulseAlpha * 0.8f), radius = 10f * scale, center = center ) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/widget/DraggableTextField.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.TextField import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.ui.widget.confirmButton import kotlin.math.abs import kotlin.math.roundToInt /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.DraggableTextField * @author: Tony Shen * @date: 2024/11/26 10:28 * @version: V1.0 <描述当前版本功能> */ @OptIn(ExperimentalFoundationApi::class) @Composable fun draggableTextField( modifier: Modifier = Modifier, text: String, canvasWidthPx: Float, canvasHeightPx: Float, density: Density, onTextChanged: (String) -> Unit, onDragged: (Offset) -> Unit ) { var offset by remember { mutableStateOf(Offset.Zero) } // 计算画布中心(相对于屏幕中心) val halfCanvasWidthPx = canvasWidthPx / 2f val halfCanvasHeightPx = canvasHeightPx / 2f // 文本输入框的尺寸(像素) val textFieldWidthPx = with(density) { 250.dp.toPx() } val textFieldHeightPx = with(density) { 130.dp.toPx() } val halfTextFieldWidthPx = textFieldWidthPx / 2f val halfTextFieldHeightPx = textFieldHeightPx / 2f Box( modifier = modifier .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) } .pointerInput(Unit) { detectDragGestures { change -> offset += change // 限制拖拽范围在画布区域内 // offset 是相对于屏幕中心的偏移,需要确保文本输入框不超出画布边界 if (abs(offset.x) > halfCanvasWidthPx - halfTextFieldWidthPx || abs(offset.y) > halfCanvasHeightPx - halfTextFieldHeightPx) { offset -= change return@detectDragGestures } } } .shadow(8.dp) .background(Color.White) .padding(16.dp) .fillMaxWidth() .wrapContentHeight(Alignment.Top) .clip(RoundedCornerShape(8.dp)) ) { Column { TextField ( value = text, onValueChange = onTextChanged, modifier = Modifier.width(220.dp) ) confirmButton(true, modifier = Modifier.align(Alignment.End).padding(top = 5.dp)) { onDragged.invoke(offset) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/widget/ImageLayerControlRenderer.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.EditorController import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.ImageLayer import kotlin.math.cos import kotlin.math.sin /** * 图像层控制点渲染器 * 负责绘制图像层的控制点、旋转手柄和边界框 */ object ImageLayerControlRenderer { // 控制点大小(像素) private const val CONTROL_POINT_SIZE = 8f // 旋转手柄长度(像素) private const val ROTATION_HANDLE_LENGTH = 30f // 控制点颜色 private val CONTROL_POINT_COLOR = Color(0xFF2196F3) // 旋转手柄颜色 private val ROTATION_HANDLE_COLOR = Color(0xFF4CAF50) // 边界框颜色 private val BOUNDARY_COLOR = Color(0xFF2196F3) /** * 计算图像层的边界框(考虑变换后的位置) */ fun calculateImageBounds( layer: ImageLayer, canvasWidth: Float, canvasHeight: Float, backgroundSize: Pair? = null ): Rect? { val bitmap = layer.image ?: return null if (bitmap.width <= 0 || bitmap.height <= 0) return null // 判断是否为背景图层(使用常量,避免硬编码) val isBackgroundLayer = layer.name == EditorController.BACKGROUND_LAYER_NAME if (isBackgroundLayer) { // 背景图层填充整个画布 return Rect(Offset.Zero, Size(canvasWidth, canvasHeight)) } // 计算适应和居中后的尺寸(与 ImageLayer.render() 逻辑一致) // 如果提供了背景层尺寸,需要考虑背景层在画布上的显示比例 val fitScale = if (backgroundSize != null) { val referenceWidth = backgroundSize.first val referenceHeight = backgroundSize.second // 计算背景层在画布上的缩放比例(背景层被拉伸到画布尺寸) val backgroundScaleX = canvasWidth / referenceWidth val backgroundScaleY = canvasHeight / referenceHeight // 计算基于背景层原始尺寸的缩放比例,确保图像尺寸不超过背景层尺寸 val referenceScaleX = referenceWidth / bitmap.width val referenceScaleY = referenceHeight / bitmap.height val referenceFitScale = minOf(referenceScaleX, referenceScaleY).coerceAtMost(1f) // 图像层在背景层原始坐标系中的缩放比例 // 然后需要乘以背景层在画布上的缩放比例,得到在画布坐标系中的缩放比例 referenceFitScale * minOf(backgroundScaleX, backgroundScaleY) } else { // 没有背景层时,基于画布尺寸 val canvasScaleX = canvasWidth / bitmap.width val canvasScaleY = canvasHeight / bitmap.height minOf(canvasScaleX, canvasScaleY).coerceAtMost(1f) } val scaledWidth = bitmap.width * fitScale val scaledHeight = bitmap.height * fitScale val centerOffsetX = (canvasWidth - scaledWidth) / 2f val centerOffsetY = (canvasHeight - scaledHeight) / 2f // 应用用户定义的变换 val transform = layer.transform // 计算变换后的四个角点 // 注意:在 ImageLayer.render() 中,withTransform 的执行顺序是(从外到内,后写的先执行): // 1. 自动平移(centerOffset)- 最外层,最后执行 // 2. 自动缩放(fitScale)- 将图像坐标系转换到适应后的坐标系 // 3. 用户平移(在适应后的坐标系中,相对于适应后图像中心) // 4. 用户旋转(相对于 adaptedPivot,在适应后的坐标系中) // 5. 用户缩放(相对于 adaptedPivot,在适应后的坐标系中)- 最内层,最先执行 // 先计算原始图像的四个角点(在图像坐标系中,0,0 到 width,height) val imageTopLeft = Offset(0f, 0f) val imageTopRight = Offset(bitmap.width.toFloat(), 0f) val imageBottomLeft = Offset(0f, bitmap.height.toFloat()) val imageBottomRight = Offset(bitmap.width.toFloat(), bitmap.height.toFloat()) val imageCenter = Offset(bitmap.width / 2f, bitmap.height / 2f) // 计算 pivot(在图像坐标系中) val pivot = if (transform.pivot == Offset.Zero) { imageCenter } else { imageCenter + transform.pivot } // 将 pivot 转换到适应后的坐标系(用于用户变换) // 注意:在 ImageLayer.render() 中,用户变换是在适应后的坐标系中进行的 // 所以这里需要先将图像坐标转换到适应后的坐标系,然后再应用用户变换 val adaptedPivot = Offset(pivot.x * fitScale, pivot.y * fitScale) // 计算变换后的角点 // 变换顺序:用户平移(translation) -> fitScale -> centerOffset // translation 在图像原始坐标系中,所以需要先应用 translation,然后应用 fitScale // 1. 应用用户平移(在图像原始坐标系中) val translation = transform.translation val translatedTopLeft = imageTopLeft + translation val translatedTopRight = imageTopRight + translation val translatedBottomLeft = imageBottomLeft + translation val translatedBottomRight = imageBottomRight + translation // 2. 应用用户缩放(相对于 pivot,在图像原始坐标系中) val scaleXFinal = transform.scaleX val scaleYFinal = transform.scaleY val scaledTopLeft = applyScale(translatedTopLeft, pivot, scaleXFinal, scaleYFinal) val scaledTopRight = applyScale(translatedTopRight, pivot, scaleXFinal, scaleYFinal) val scaledBottomLeft = applyScale(translatedBottomLeft, pivot, scaleXFinal, scaleYFinal) val scaledBottomRight = applyScale(translatedBottomRight, pivot, scaleXFinal, scaleYFinal) // 3. 应用用户旋转(相对于 pivot,在图像原始坐标系中) val rotation = transform.rotation val rotatedTopLeft = applyRotation(scaledTopLeft, pivot, rotation) val rotatedTopRight = applyRotation(scaledTopRight, pivot, rotation) val rotatedBottomLeft = applyRotation(scaledBottomLeft, pivot, rotation) val rotatedBottomRight = applyRotation(scaledBottomRight, pivot, rotation) // 4. 应用自动缩放(fitScale)- 将图像坐标系转换到适应后的坐标系 val adaptedTopLeft = Offset(rotatedTopLeft.x * fitScale, rotatedTopLeft.y * fitScale) val adaptedTopRight = Offset(rotatedTopRight.x * fitScale, rotatedTopRight.y * fitScale) val adaptedBottomLeft = Offset(rotatedBottomLeft.x * fitScale, rotatedBottomLeft.y * fitScale) val adaptedBottomRight = Offset(rotatedBottomRight.x * fitScale, rotatedBottomRight.y * fitScale) // 5. 应用自动平移(centerOffset)- 在画布坐标系中 val finalTopLeft = adaptedTopLeft + Offset(centerOffsetX, centerOffsetY) val finalTopRight = adaptedTopRight + Offset(centerOffsetX, centerOffsetY) val finalBottomLeft = adaptedBottomLeft + Offset(centerOffsetX, centerOffsetY) val finalBottomRight = adaptedBottomRight + Offset(centerOffsetX, centerOffsetY) // 计算边界框 val minX = minOf(finalTopLeft.x, finalTopRight.x, finalBottomLeft.x, finalBottomRight.x) val maxX = maxOf(finalTopLeft.x, finalTopRight.x, finalBottomLeft.x, finalBottomRight.x) val minY = minOf(finalTopLeft.y, finalTopRight.y, finalBottomLeft.y, finalBottomRight.y) val maxY = maxOf(finalTopLeft.y, finalTopRight.y, finalBottomLeft.y, finalBottomRight.y) return Rect( offset = Offset(minX, minY), size = Size(maxX - minX, maxY - minY) ) } /** * 计算图像层的中心点(考虑变换后) */ fun calculateImageCenter( layer: ImageLayer, canvasWidth: Float, canvasHeight: Float, backgroundSize: Pair? = null ): Offset? { val bounds = calculateImageBounds(layer, canvasWidth, canvasHeight, backgroundSize) ?: return null return Offset(bounds.center.x, bounds.center.y) } /** * 计算旋转手柄的位置 */ fun calculateRotationHandlePosition( layer: ImageLayer, canvasWidth: Float, canvasHeight: Float, backgroundSize: Pair? = null ): Offset? { val center = calculateImageCenter(layer, canvasWidth, canvasHeight, backgroundSize) ?: return null val bounds = calculateImageBounds(layer, canvasWidth, canvasHeight, backgroundSize) ?: return null // 旋转手柄在图像上方中心 val handleOffset = Offset(0f, -bounds.height / 2f - ROTATION_HANDLE_LENGTH) return center + handleOffset } /** * 计算所有控制点的位置(四个角、旋转手柄和裁剪控制点) */ fun calculateControlPoints( layer: ImageLayer, canvasWidth: Float, canvasHeight: Float, backgroundSize: Pair? = null ): List { val bounds = calculateImageBounds(layer, canvasWidth, canvasHeight, backgroundSize) ?: return emptyList() val rotationHandle = calculateRotationHandlePosition(layer, canvasWidth, canvasHeight, backgroundSize) val points = mutableListOf() // 四个角的控制点 points.add(ControlPoint(ControlPointType.CORNER_TOP_LEFT, bounds.topLeft)) points.add(ControlPoint(ControlPointType.CORNER_TOP_RIGHT, bounds.topRight)) points.add(ControlPoint(ControlPointType.CORNER_BOTTOM_LEFT, bounds.bottomLeft)) points.add(ControlPoint(ControlPointType.CORNER_BOTTOM_RIGHT, bounds.bottomRight)) // 旋转手柄 if (rotationHandle != null) { points.add(ControlPoint(ControlPointType.ROTATION_HANDLE, rotationHandle)) } // 裁剪控制点(如果存在裁剪区域) // 注意:裁剪控制点的计算比较复杂,需要考虑所有变换 // 这里先简化实现,后续可以优化 val cropRect = layer.transform.cropRect if (cropRect != null) { // 裁剪区域在图像坐标系中,需要转换到画布坐标系 // 简化实现:使用 bounds 作为参考,后续可以完善 // TODO: 完善裁剪控制点的计算,考虑所有变换 } return points } /** * 绘制图像层的控制点和边界框 */ fun drawControls( drawScope: DrawScope, layer: ImageLayer, canvasWidth: Float, canvasHeight: Float, backgroundSize: Pair? = null ) { val bounds = calculateImageBounds(layer, canvasWidth, canvasHeight, backgroundSize) ?: return val controlPoints = calculateControlPoints(layer, canvasWidth, canvasHeight, backgroundSize) // 绘制边界框 drawScope.drawRect( color = BOUNDARY_COLOR.copy(alpha = 0.5f), style = Stroke(width = 1f), topLeft = bounds.topLeft, size = bounds.size ) // 绘制控制点 controlPoints.forEach { point -> val color = when (point.type) { ControlPointType.ROTATION_HANDLE -> ROTATION_HANDLE_COLOR else -> CONTROL_POINT_COLOR } drawScope.drawCircle( color = color, radius = CONTROL_POINT_SIZE, center = point.position ) // 绘制控制点外圈 drawScope.drawCircle( color = Color.White, radius = CONTROL_POINT_SIZE + 1f, style = Stroke(width = 1f), center = point.position ) } // 绘制旋转手柄连线 val rotationHandle = controlPoints.find { it.type == ControlPointType.ROTATION_HANDLE } val center = calculateImageCenter(layer, canvasWidth, canvasHeight, backgroundSize) if (rotationHandle != null && center != null) { drawScope.drawLine( color = ROTATION_HANDLE_COLOR.copy(alpha = 0.5f), start = center, end = rotationHandle.position, strokeWidth = 1f ) } } /** * 检查点是否在控制点附近 */ fun hitTestControlPoint( point: Offset, layer: ImageLayer, canvasWidth: Float, canvasHeight: Float, backgroundSize: Pair? = null ): ControlPoint? { val controlPoints = calculateControlPoints(layer, canvasWidth, canvasHeight, backgroundSize) val hitRadius = CONTROL_POINT_SIZE * 2f return controlPoints.firstOrNull { controlPoint -> val distance = (point - controlPoint.position).getDistance() distance <= hitRadius } } // 辅助函数:应用缩放 private fun applyScale(point: Offset, pivot: Offset, scaleX: Float, scaleY: Float): Offset { val translated = point - pivot val scaled = Offset(translated.x * scaleX, translated.y * scaleY) return scaled + pivot } // 辅助函数:应用旋转 private fun applyRotation(point: Offset, pivot: Offset, rotation: Float): Offset { val translated = point - pivot val rad = Math.toRadians(rotation.toDouble()) val cos = cos(rad).toFloat() val sin = sin(rad).toFloat() val rotated = Offset( translated.x * cos - translated.y * sin, translated.x * sin + translated.y * cos ) return rotated + pivot } } /** * 控制点类型 */ enum class ControlPointType { CORNER_TOP_LEFT, CORNER_TOP_RIGHT, CORNER_BOTTOM_LEFT, CORNER_BOTTOM_RIGHT, ROTATION_HANDLE, CROP_TOP_LEFT, CROP_TOP_RIGHT, CROP_BOTTOM_LEFT, CROP_BOTTOM_RIGHT, CROP_TOP, CROP_BOTTOM, CROP_LEFT, CROP_RIGHT } /** * 控制点数据 */ data class ControlPoint( val type: ControlPointType, val position: Offset ) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/widget/LayerPanel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonDefaults import androidx.compose.material.Checkbox import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.OutlinedTextField import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.AlertDialog import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Lock import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.zIndex import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.EditorController import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.LayerType import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.chooseImage import cn.netdiscovery.monica.utils.getBufferedImage import org.slf4j.LoggerFactory import java.util.UUID import kotlin.collections.asReversed @Composable fun LayerPanel( editorController: EditorController, state: ApplicationState, modifier: Modifier = Modifier.Companion ) { val density = LocalDensity.current val layers by editorController.layerManager.layers.collectAsState() val activeLayer by editorController.layerManager.activeLayer.collectAsState() var editingLayerId by remember { mutableStateOf(null) } var editingName by remember { mutableStateOf("") } var deleteConfirmLayerId by remember { mutableStateOf(null) } var draggedLayerId by remember { mutableStateOf(null) } var dragOffset by remember { mutableStateOf(0f) } val displayLayers = remember(layers) { layers.asReversed() } val logger = remember { LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) } Column( modifier = modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = "图层", style = MaterialTheme.typography.h6, modifier = Modifier.Companion.padding(bottom = 4.dp) ) val shapeLayerCount = layers.count { it.type == LayerType.SHAPE } val canAddShapeLayer = editorController.canAddShapeLayer() Row( modifier = Modifier.Companion.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { OutlinedButton( onClick = { val result = editorController.addShapeLayer("形状图层") if (result == null && !canAddShapeLayer) { state.showTray("最多只能创建 1 个形状层", "提示") } }, modifier = Modifier.Companion.weight(1f), enabled = canAddShapeLayer, colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colors.primary ) ) { Icon( Icons.Default.Add, contentDescription = null, modifier = Modifier.Companion.size(14.dp) ) Spacer(modifier = Modifier.Companion.width(4.dp)) Text( if (canAddShapeLayer) "形状层" else "已达上限", style = MaterialTheme.typography.caption ) } OutlinedButton( onClick = { chooseImage(state) { file -> try { var bufferedImage = getBufferedImage(file, state) // 如果添加的图像超过背景图,自动缩放 val bgSize = editorController.getBackgroundSize() if (bgSize != null) { val bgWidth = bgSize.first.toInt() val bgHeight = bgSize.second.toInt() // 如果图像超过背景层大小,缩放到不超过背景层 if (bufferedImage.width > bgWidth || bufferedImage.height > bgHeight) { val scaleX = bgWidth.toFloat() / bufferedImage.width val scaleY = bgHeight.toFloat() / bufferedImage.height val scale = minOf(scaleX, scaleY) val newWidth = (bufferedImage.width * scale).toInt() val newHeight = (bufferedImage.height * scale).toInt() val scaledImage = java.awt.Image.SCALE_SMOOTH val resizedBufImage = bufferedImage.getScaledInstance(newWidth, newHeight, scaledImage) bufferedImage = java.awt.image.BufferedImage(newWidth, newHeight, java.awt.image.BufferedImage.TYPE_INT_RGB) val g2d = bufferedImage.createGraphics() g2d.drawImage(resizedBufImage, 0, 0, null) g2d.dispose() logger.info("自动缩放图像: ${bufferedImage.width}x${bufferedImage.height} (原始: ${getBufferedImage(file, state).width}x${getBufferedImage(file, state).height})") } } val imageBitmap = bufferedImage.toComposeImageBitmap() val imageLayerCount = layers.count { it.type == LayerType.IMAGE } + 1 val layerName = "图像图层 $imageLayerCount" editorController.createImageLayer(layerName, imageBitmap) logger.info("成功添加图像层: $layerName, 文件: ${file.name}") } catch (e: Exception) { logger.error("添加图像层失败: ${file.name}", e) state.showTray("添加图像层失败: ${e.message}", "错误") } } }, modifier = Modifier.Companion.weight(1f), colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colors.primary ) ) { Icon( Icons.Default.Add, contentDescription = null, modifier = Modifier.Companion.size(14.dp) ) Spacer(modifier = Modifier.Companion.width(4.dp)) Text("图像层", style = MaterialTheme.typography.caption) } } Spacer(modifier = Modifier.Companion.height(4.dp)) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { displayLayers.forEachIndexed { displayIndex, layer -> val actualIndex = layers.indexOfFirst { it.id == layer.id } val isActive = activeLayer?.id == layer.id val isDragging = draggedLayerId == layer.id val upEnabled = actualIndex < layers.lastIndex val downEnabled = actualIndex > 0 val cardColor = when { isDragging -> MaterialTheme.colors.primary.copy(alpha = 0.15f) isActive -> MaterialTheme.colors.primary.copy(alpha = 0.08f) else -> MaterialTheme.colors.surface } val borderColor = when { isDragging -> MaterialTheme.colors.primary.copy(alpha = 0.6f) isActive -> MaterialTheme.colors.primary.copy(alpha = 0.4f) else -> MaterialTheme.colors.onSurface.copy(alpha = 0.08f) } Surface( shape = RoundedCornerShape(10.dp), color = cardColor, border = BorderStroke( width = if (isDragging || isActive) 2.dp else 1.dp, color = borderColor ), elevation = if (isDragging) 8.dp else 0.dp, modifier = Modifier.Companion .fillMaxWidth() .zIndex(if (isDragging) 1f else 0f) .pointerInput(layer.id, layers.size, density.density) { val itemHeightPx = with(density) { 80.dp.toPx() } val threshold = itemHeightPx * 0.5f detectDragGestures( onDragStart = { draggedLayerId = layer.id dragOffset = 0f }, onDrag = { change, dragAmount -> dragOffset += dragAmount.y // 重新计算当前索引(因为 layers 可能已更新) val currentLayers = editorController.layerManager.layers.value val currentIndex = currentLayers.indexOfFirst { it.id == layer.id } // 注意:displayLayers 是反转的,所以向下拖拽(dragOffset > 0)应该向上移动(index 增加) if (dragOffset > threshold && currentIndex < currentLayers.lastIndex) { // 向下拖拽,在列表中向上移动(index 增加) val targetIndex = currentIndex + 1 editorController.layerManager.moveLayerTo(layer.id, targetIndex) dragOffset = 0f } else if (dragOffset < -threshold && currentIndex > 0) { // 向上拖拽,在列表中向下移动(index 减少) val targetIndex = currentIndex - 1 editorController.layerManager.moveLayerTo(layer.id, targetIndex) dragOffset = 0f } }, onDragEnd = { draggedLayerId = null dragOffset = 0f } ) } ) { Column( modifier = Modifier.Companion .clickable { editorController.setActiveLayer(layer.id) } .padding(10.dp) ) { Row( verticalAlignment = Alignment.Companion.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { // 可见性复选框 Checkbox( checked = layer.visible, onCheckedChange = { checked -> editorController.layerManager.setLayerVisibility(layer.id, checked) }, modifier = Modifier.Companion.size(20.dp) ) // 图层类型图标 Box( modifier = Modifier.Companion.size(32.dp), contentAlignment = Alignment.Companion.Center ) { Surface( shape = androidx.compose.foundation.shape.RoundedCornerShape(6.dp), color = if (isActive) { MaterialTheme.colors.primary.copy(alpha = 0.1f) } else { MaterialTheme.colors.onSurface.copy(alpha = 0.05f) }, modifier = Modifier.Companion.size(32.dp) ) { Box(contentAlignment = Alignment.Companion.Center) { Text( text = if (layer.type == LayerType.IMAGE) "图" else "形", style = MaterialTheme.typography.caption.copy( color = if (isActive) { MaterialTheme.colors.primary } else { MaterialTheme.colors.onSurface.copy(alpha = 0.6f) }, fontWeight = FontWeight.Companion.Bold ) ) } } } // 图层信息 Column( modifier = Modifier.Companion.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) ) { if (editingLayerId == layer.id) { OutlinedTextField( value = editingName, onValueChange = { editingName = it }, singleLine = true, modifier = Modifier.Companion.fillMaxWidth(), textStyle = MaterialTheme.typography.body2 ) } else { Text( text = layer.name, style = MaterialTheme.typography.body2.copy( color = if (isActive) { MaterialTheme.colors.primary } else { MaterialTheme.colors.onSurface }, fontWeight = if (isActive) FontWeight.Companion.SemiBold else FontWeight.Companion.Normal ), maxLines = 1, overflow = TextOverflow.Companion.Ellipsis ) } Row( verticalAlignment = Alignment.Companion.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { Text( text = layer.type.toDisplayName(), style = MaterialTheme.typography.caption.copy( color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) ) ) if (layer.type == LayerType.SHAPE && isActive) { Text( text = "• 当前绘制", style = MaterialTheme.typography.caption.copy( color = MaterialTheme.colors.primary, fontWeight = FontWeight.Companion.Bold ) ) } } } } // 操作按钮区域 if (editingLayerId == layer.id) { Row( modifier = Modifier.Companion .fillMaxWidth() .padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { OutlinedButton( onClick = { editorController.layerManager.renameLayer( layer.id, editingName.trim().ifEmpty { layer.name }) editingLayerId = null }, modifier = Modifier.Companion.weight(1f), colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colors.primary ) ) { Text("保存", style = MaterialTheme.typography.caption) } OutlinedButton( onClick = { editingLayerId = null }, modifier = Modifier.Companion.weight(1f), colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) ) ) { Text("取消", style = MaterialTheme.typography.caption) } } } else { Row( modifier = Modifier.Companion .fillMaxWidth() .padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { IconButton( onClick = { editorController.layerManager.setLayerLocked(layer.id, !layer.locked) }, modifier = Modifier.Companion.size(32.dp) ) { Icon( Icons.Default.Lock, contentDescription = if (layer.locked) "解锁" else "锁定", modifier = Modifier.Companion.size(16.dp), tint = if (layer.locked) { MaterialTheme.colors.error } else { MaterialTheme.colors.onSurface.copy(alpha = 0.6f) } ) } IconButton( onClick = { editingLayerId = layer.id editingName = layer.name }, modifier = Modifier.Companion.size(32.dp) ) { Icon( Icons.Default.Edit, contentDescription = "重命名", modifier = Modifier.Companion.size(16.dp), tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) ) } IconButton( onClick = { editorController.layerManager.moveLayerUp(layer.id) }, enabled = upEnabled, modifier = Modifier.Companion.size(32.dp) ) { Text( "↑", style = MaterialTheme.typography.caption, color = if (upEnabled) { MaterialTheme.colors.onSurface.copy(alpha = 0.6f) } else { MaterialTheme.colors.onSurface.copy(alpha = 0.3f) } ) } IconButton( onClick = { editorController.layerManager.moveLayerDown(layer.id) }, enabled = downEnabled, modifier = Modifier.Companion.size(32.dp) ) { Text( "↓", style = MaterialTheme.typography.caption, color = if (downEnabled) { MaterialTheme.colors.onSurface.copy(alpha = 0.6f) } else { MaterialTheme.colors.onSurface.copy(alpha = 0.3f) } ) } IconButton( onClick = { if (editorController.isBackgroundLayer(layer)) { state.showTray("无法删除背景图层", "提示") } else { deleteConfirmLayerId = layer.id } }, modifier = Modifier.Companion.size(32.dp) ) { Icon( Icons.Default.Delete, contentDescription = "删除", modifier = Modifier.Companion.size(16.dp), tint = MaterialTheme.colors.error.copy(alpha = 0.7f) ) } } } } } } } } // 删除确认对话框 deleteConfirmLayerId?.let { layerId -> val layerToDelete = layers.firstOrNull { it.id == layerId } if (layerToDelete != null) { AlertDialog( onDismissRequest = { deleteConfirmLayerId = null }, title = { Text("确认删除") }, text = { Text("确定要删除图层 \"${layerToDelete.name}\" 吗?此操作无法撤销。") }, confirmButton = { TextButton( onClick = { editorController.removeLayer(layerId) deleteConfirmLayerId = null } ) { Text("删除", color = MaterialTheme.colors.error) } }, dismissButton = { TextButton( onClick = { deleteConfirmLayerId = null } ) { Text("取消") } } ) } } } private fun LayerType.toDisplayName(): String = when (this) { LayerType.IMAGE -> "图像层" LayerType.SHAPE -> "形状层" } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/widget/ShapeDrawingPropertiesMenuDialog.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card import androidx.compose.material.Slider import androidx.compose.material.SliderDefaults import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.geometry.Border import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.model.ShapeProperties import cn.netdiscovery.monica.ui.widget.properties.ExposedSelectionMenu import cn.netdiscovery.monica.i18n.getCurrentStringResource /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.ShapeDrawingPropertiesMenuDialog * @author: Tony Shen * @date: 2024/11/26 10:33 * @version: V1.0 <描述当前版本功能> */ @Composable fun ShapeDrawingPropertiesMenuDialog( shapeProperties: ShapeProperties, onDismiss: (ShapeProperties) -> Unit ) { val i18nState = getCurrentStringResource() var alpha by remember { mutableStateOf(shapeProperties.alpha) } var fontSize by remember { mutableStateOf(shapeProperties.fontSize) } var fill by remember { mutableStateOf(shapeProperties.fill) } var border by remember { mutableStateOf(shapeProperties.border) } Dialog(onDismissRequest = { // 返回更新后的属性 val updatedProperties = shapeProperties.copy( alpha = alpha, fontSize = fontSize, fill = fill, border = border ) onDismiss(updatedProperties) }) { Card( elevation = 2.dp, shape = RoundedCornerShape(8.dp), modifier = Modifier.padding(vertical = 8.dp) ) { Column(modifier = Modifier.padding(8.dp)) { Text( text = i18nState.get("alpha") + ": ${alpha}", fontSize = 16.sp, modifier = Modifier.padding(horizontal = 12.dp) ) Slider( value = alpha, onValueChange = { alpha = it }, valueRange = 0f..1f, onValueChangeFinished = {}, colors = SliderDefaults.colors() ) Text( text = i18nState.get("font_size") + ": ${fontSize.toInt()}", fontSize = 16.sp, modifier = Modifier.padding(horizontal = 12.dp) ) Slider( value = fontSize, onValueChange = { fontSize = it }, valueRange = 1f..100f, onValueChangeFinished = {}, colors = SliderDefaults.colors() ) ExposedSelectionMenu(title = i18nState.get("fill"), index = when (fill) { false -> 0 true -> 1 }, options = listOf("False", "True"), onSelected = { fill = when (it) { 0 -> false 1 -> true else -> false } } ) ExposedSelectionMenu(title = i18nState.get("border"), index = when (border) { Border.No -> 0 Border.Dot -> 1 Border.Dash -> 2 Border.DashDot -> 3 Border.Line -> 4 }, options = listOf("No", "Dot", "Dash", "DashDot", "Line"), onSelected = { border = when (it) { 0 -> Border.No 1 -> Border.Dot 2 -> Border.Dash 3 -> Border.DashDot 4 -> Border.Line else -> Border.No } } ) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/shapedrawing/widget/TextDrawer.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import org.jetbrains.skia.Font import org.jetbrains.skia.Paint import org.jetbrains.skia.TextLine /** * * @FileName: * cn.netdiscovery.monica.ui.controlpanel.shapedrawing.widget.TextDrawerImpl * @author: Tony Shen * @date: 2024/11/20 11:59 * @version: V1.0 <描述当前版本功能> */ object TextDrawer: cn.netdiscovery.monica.ui.controlpanel.shapedrawing.geometry.TextDrawer { override fun text(canvas: Canvas, pos: Offset, text: List, color: Color, fontSize: Float) { println("=== TextDrawer.text 开始 ===") println("传入位置: $pos") println("文本内容: $text") println("字体大小: $fontSize") val paint = Paint() paint.color = color.toArgb() val font = Font(null, fontSize) val subscript = Font(null, fontSize - 10) var current = pos.x text.forEachIndexed { index, str -> val line = TextLine.make(str, if (index % 2 == 0) font else subscript) // 让文字显示在控件的中心位置 // pos.x 是控件中心,我们需要减去文字宽度的一半来水平居中 val textWidth = line.width val drawX = pos.x - textWidth / 2 // 计算文本基线位置 // pos.y 是文本的中心位置,我们希望文字垂直居中显示 // 由于Skia的drawTextLine中Y坐标是基线位置,我们需要计算正确的基线 val fontHeight = if (index % 2 == 0) fontSize else (fontSize - 10) // 文字应该垂直居中显示在 pos.y 位置 // 对于大多数字体,基线大约在字体高度的 70-80% 位置 // 要让文本中心在 pos.y,基线应该在 pos.y + (fontHeight * 0.3) 左右 // 但考虑到不同字体的差异,使用更精确的计算: // 文本中心 = 基线 - fontHeight * 0.7 // 所以:基线 = 文本中心 + fontHeight * 0.7 = pos.y + fontHeight * 0.7 // 但这样会让文本偏下,应该使用:基线 = pos.y + fontHeight * 0.3 val drawY = pos.y + fontHeight * 0.3f println("绘制文本: '$str' 在 ($drawX, $drawY)") println(" - 原始位置: $pos") println(" - 文字宽度: $textWidth") println(" - 字体高度: $fontHeight") println(" - X居中计算: pos.x(${pos.x}) - textWidth/2(${textWidth/2}) = $drawX") println(" - Y基线计算: pos.y(${pos.y}) + fontHeight*0.3(${fontHeight*0.3f}) = $drawY") canvas.nativeCanvas.drawTextLine(line, drawX, drawY, paint) current += line.width } println("=== TextDrawer.text 结束 ===") } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/webscreenshot/WebScreenshotView.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.webscreenshot import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.exception.ErrorSeverity import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.ui.widget.* import cn.netdiscovery.monica.utils.WebScreenshotOptions import cn.netdiscovery.monica.utils.checkNodeInstalled import org.koin.compose.koinInject /** * 网页截图视图 * * @author: Tony Shen * @date: 2026/01/12 * @version: V1.0 */ @Composable fun webScreenshot(state: ApplicationState) { val i18nState = rememberI18nState() val viewModel: WebScreenshotViewModel = koinInject() var urlText by remember { mutableStateOf("https://") } var fullPage by remember { mutableStateOf(true) } var waitUntil by remember { mutableStateOf("networkidle") } var timeoutText by remember { mutableStateOf("30000") } var viewportWidthText by remember { mutableStateOf("") } var viewportHeightText by remember { mutableStateOf("") } var clarityScaleText by remember { mutableStateOf("2.0") } var nodeInstalled by remember { mutableStateOf(false) } // 检查 Node.js 环境 LaunchedEffect(Unit) { nodeInstalled = checkNodeInstalled() } Column( modifier = Modifier .fillMaxSize() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { // 标题 title( modifier = Modifier.padding(bottom = 8.dp), text = i18nState.getString("web_screenshot"), color = MaterialTheme.colors.onBackground ) // Node.js 环境检查提示 if (!nodeInstalled) { Card( modifier = Modifier.fillMaxWidth(), backgroundColor = MaterialTheme.colors.error.copy(alpha = 0.1f), shape = RoundedCornerShape(8.dp) ) { Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = i18nState.getString("nodejs_not_installed"), color = MaterialTheme.colors.error, style = MaterialTheme.typography.body2 ) Spacer(modifier = Modifier.height(8.dp)) Text( text = i18nState.getString("please_install_nodejs"), color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f), style = MaterialTheme.typography.caption ) } } } // URL 输入框 Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), elevation = 2.dp ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = i18nState.getString("website_url"), style = MaterialTheme.typography.subtitle1 ) OutlinedTextField( value = urlText, onValueChange = { urlText = it }, modifier = Modifier.fillMaxWidth(), label = { Text(i18nState.getString("enter_url")) }, placeholder = { Text("https://example.com") }, singleLine = true ) // 截图选项 Divider(modifier = Modifier.padding(vertical = 8.dp)) Text( text = i18nState.getString("screenshot_options"), style = MaterialTheme.typography.subtitle2 ) // 全页截图选项 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text(text = i18nState.getString("full_page_screenshot")) Switch( checked = fullPage, onCheckedChange = { fullPage = it } ) } // 等待策略 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text(text = i18nState.getString("wait_until")) var expanded by remember { mutableStateOf(false) } Box { Button( onClick = { expanded = true }, modifier = Modifier.width(150.dp) ) { Text(waitUntil, style = MaterialTheme.typography.body2) } DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { listOf("load", "domcontentloaded", "networkidle").forEach { option -> DropdownMenuItem(onClick = { waitUntil = option expanded = false }) { Text(option) } } } } } // 超时设置 basicTextFieldWithTitle( titleText = i18nState.getString("timeout_ms"), value = timeoutText, width = 120.dp, onValueChange = { timeoutText = it } ) // 视口尺寸(可选) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { basicTextFieldWithTitle( titleText = i18nState.getString("viewport_width"), value = viewportWidthText, width = 100.dp, onValueChange = { viewportWidthText = it } ) basicTextFieldWithTitle( titleText = i18nState.getString("viewport_height"), value = viewportHeightText, width = 100.dp, onValueChange = { viewportHeightText = it } ) } basicTextFieldWithTitle( titleText = i18nState.getString("screenshot_clarity"), value = clarityScaleText, width = 120.dp, onValueChange = { clarityScaleText = it } ) } } // 操作按钮 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Button( onClick = { if (!nodeInstalled) { showError( ErrorType.NETWORK_ERROR, ErrorSeverity.MEDIUM, i18nState.getString("nodejs_not_installed"), i18nState.getString("please_install_nodejs") ) return@Button } if (urlText.isBlank() || !urlText.startsWith("http")) { showError( ErrorType.VALIDATION_ERROR, ErrorSeverity.LOW, i18nState.getString("invalid_url"), i18nState.getString("please_enter_valid_url") ) return@Button } val clarityScale = clarityScaleText.toDoubleOrNull() if (clarityScale == null || clarityScale <= 0.0 || clarityScale > 4.0) { showError( ErrorType.VALIDATION_ERROR, ErrorSeverity.LOW, i18nState.getString("invalid_screenshot_clarity"), i18nState.getString("please_enter_valid_screenshot_clarity") ) return@Button } val options = WebScreenshotOptions( fullPage = fullPage, waitUntil = waitUntil, timeout = timeoutText.toLongOrNull() ?: 30000L, viewportWidth = viewportWidthText.toIntOrNull(), viewportHeight = viewportHeightText.toIntOrNull(), deviceScaleFactor = clarityScale ) viewModel.captureWebScreenshot(state, urlText.trim(), options) }, modifier = Modifier.weight(1f), enabled = nodeInstalled && urlText.isNotBlank() ) { Text(i18nState.getString("capture_screenshot")) } OutlinedButton( onClick = { urlText = "https://" fullPage = true waitUntil = "networkidle" timeoutText = "30000" viewportWidthText = "" viewportHeightText = "" clarityScaleText = "2.0" }, modifier = Modifier.weight(1f) ) { Text(i18nState.getString("reset")) } } // 使用说明 Card( modifier = Modifier.fillMaxWidth(), backgroundColor = MaterialTheme.colors.surface.copy(alpha = 0.5f), shape = RoundedCornerShape(8.dp) ) { Column( modifier = Modifier.padding(12.dp) ) { Text( text = i18nState.getString("usage_tips"), style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f) ) } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/controlpanel/webscreenshot/WebScreenshotViewModel.kt ================================================ package cn.netdiscovery.monica.ui.controlpanel.webscreenshot import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.WebScreenshotOptions import cn.netdiscovery.monica.utils.checkNodeInstalled import cn.netdiscovery.monica.utils.loadWebScreenshotToState import org.slf4j.Logger import org.slf4j.LoggerFactory /** * 网页截图 ViewModel * * @author: Tony Shen * @date: 2026/01/12 * @version: V1.0 */ class WebScreenshotViewModel { private val logger: Logger = LoggerFactory.getLogger(WebScreenshotViewModel::class.java) /** * 捕获网页截图 */ fun captureWebScreenshot( state: ApplicationState, url: String, options: WebScreenshotOptions = WebScreenshotOptions() ) { logger.info("开始捕获网页截图: $url") loadWebScreenshotToState(state, url, options) } /** * 检查 Node.js 环境 */ fun checkEnvironment(): Boolean { return checkNodeInstalled() } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/i18n/ComposeI18n.kt ================================================ package cn.netdiscovery.monica.ui.i18n import androidx.compose.runtime.* import cn.netdiscovery.monica.i18n.LocalizationManager import cn.netdiscovery.monica.i18n.Language /** * Compose专用的国际化状态管理 * 用于在UI中响应语言变化 */ @Composable fun rememberI18nState(): I18nState { // 创建响应式的语言状态 var currentLanguage by remember { mutableStateOf(LocalizationManager.currentLanguage) } // 监听语言变化 LaunchedEffect(Unit) { LocalizationManager.addLanguageChangeListener { // 当语言变化时,更新Compose状态 currentLanguage = LocalizationManager.currentLanguage } } return remember(currentLanguage) { I18nState(currentLanguage) } } /** * 国际化状态类 */ class I18nState(private val language: Language) { /** * 获取当前语言的字符串资源 */ fun getString(key: String): String { return LocalizationManager.getString(key) } /** * 获取带参数的字符串资源 */ fun getString(key: String, vararg args: Any): String { return LocalizationManager.getString(key, *args) } /** * 获取当前语言 */ fun getCurrentLanguage(): Language = language /** * 获取语言显示名称 */ fun getLanguageDisplayName(): String { return "${language.flag} ${language.displayName}" } /** * 切换语言 */ fun toggleLanguage() { val newLang = if (language == Language.CHINESE) Language.ENGLISH else Language.CHINESE LocalizationManager.setLanguage(newLang) } /** * 设置特定语言 */ fun setLanguage(newLanguage: Language) { LocalizationManager.setLanguage(newLanguage) } /** * 重置为系统语言 */ fun resetToSystemLanguage() { val systemLang = Language.getSystemLanguage() LocalizationManager.setLanguage(systemLang) } /** * 获取切换按钮文本 */ fun getToggleButtonText(): String { return if (language == Language.CHINESE) "切换到英文" else "Switch to Chinese" } } /** * 便捷的字符串获取函数 */ @Composable fun getString(key: String): String { val i18nState = rememberI18nState() return i18nState.getString(key) } /** * 便捷的带参数字符串获取函数 */ @Composable fun getString(key: String, vararg args: Any): String { val i18nState = rememberI18nState() return i18nState.getString(key, *args) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/main/ContentPanel.kt ================================================ package cn.netdiscovery.monica.ui.main import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.controlpanel.* import cn.netdiscovery.monica.ui.controlpanel.ai.aiView import cn.netdiscovery.monica.ui.i18n.rememberI18nState /** * 内容面板组件 - 显示选中模块的详细功能 * @author: Tony Shen * @date: 2025/9/8 * @version: V1.0 */ @Composable fun ContentPanel( state: ApplicationState, modifier: Modifier = Modifier ) { val i18nState = rememberI18nState() Card( modifier = modifier .fillMaxHeight() .width(320.dp), shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomEnd = 16.dp, bottomStart = 16.dp), elevation = 8.dp, backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier .fillMaxHeight() .padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { when { state.isBasic -> { Text( text = i18nState.getString("basic_functions"), style = MaterialTheme.typography.h6, color = MaterialTheme.colors.primary ) basicView(state) } state.isAI -> { Text( text = i18nState.getString("ai_laboratory"), style = MaterialTheme.typography.h6, color = MaterialTheme.colors.primary ) aiView(state) } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/main/Dialogs.kt ================================================ package cn.netdiscovery.monica.ui.main import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.config.* import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.utils.Action import picUrl /** * * @FileName: * cn.netdiscovery.monica.ui.main.Dialogs * @author: Tony Shen * @date: 2025/4/17 11:57 * @version: V1.0 <描述当前版本功能> */ /** * 加载网络图片的对话框 */ @Composable fun openURLDialog(onConfirm: Action, onDismiss: Action) { val i18nState = rememberI18nState() AlertDialog( modifier = Modifier.width(600.dp).height(200.dp), onDismissRequest = onDismiss, title = { Text(text = i18nState.getString("load_network_image_dialog")) }, text = { Column( verticalArrangement = Arrangement.Center ) { TextField( modifier = Modifier.fillMaxWidth(), value = picUrl, onValueChange = { picUrl = it } ) } }, confirmButton = { TextButton( onClick = { onConfirm.invoke() } ) { Text(i18nState.getString("confirm")) } }, dismissButton = { TextButton( onClick = { onDismiss.invoke() } ) { Text(i18nState.getString("cancel")) } } ) } @Composable fun showVersionInfo(onClick: Action) { val i18nState = rememberI18nState() AlertDialog(onDismissRequest = {}, title = { Text(i18nState.getString("monica_software_info")) }, text = { Column { val versionInfo = if (isProVersion) i18nState.getString("pro_version") else i18nState.getString("test_version") Text(i18nState.getString("monica_version_info", appVersion, versionInfo, buildTime)) Text("OS: $os, $osVersion, $arch") Text("JDK: $javaVersion, $javaVendor") Text("Kotlin: $kotlinVersion, Compose Desktop: $composeVersion") Text(i18nState.getString("opencv_version_info", openCVVersion, imageProcessVersion)) Text(i18nState.getString("copyright_info")) Text("Wechat: fengzhizi715") Text(i18nState.getString("github_url")) } }, confirmButton = { Button(onClick = { onClick.invoke() }) { Text(i18nState.getString("close")) } }) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/main/GeneralSettingsDialog.kt ================================================ package cn.netdiscovery.monica.ui.main import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.AlertDialog import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.Checkbox import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow import androidx.compose.material.TabRowDefaults import androidx.compose.material.TabRowDefaults.tabIndicatorOffset import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.config.STATUS_HTTP_SERVER_FAILED import cn.netdiscovery.monica.config.STATUS_HTTP_SERVER_OK import cn.netdiscovery.monica.exception.ErrorSeverity import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.http.healthCheck import cn.netdiscovery.monica.i18n.Language import cn.netdiscovery.monica.i18n.LocalizationManager import cn.netdiscovery.monica.rxcache.clearData import cn.netdiscovery.monica.rxcache.initFilterParamsConfig import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.ui.theme.ColorTheme import cn.netdiscovery.monica.ui.widget.basicTextFieldWithTitle import cn.netdiscovery.monica.ui.widget.desktopLazyRow import cn.netdiscovery.monica.utils.Action import cn.netdiscovery.monica.utils.extensions.isValidUrl import cn.netdiscovery.monica.utils.getValidateField /** * * @FileName: * cn.netdiscovery.monica.ui.main.GeneralSettingsDialog * @author: Tony Shen * @date: 2025/9/9 18:09 * @version: V1.0 <描述当前版本功能> */ @Composable fun generalSettings(state: ApplicationState, onClick: Action) { val i18nState = rememberI18nState() var rText by remember { mutableStateOf(state.outputBoxRText.toString()) } var gText by remember { mutableStateOf(state.outputBoxGText.toString()) } var bText by remember { mutableStateOf(state.outputBoxBText.toString()) } var sizeText by remember { mutableStateOf(state.sizeText.toString()) } var maxHistorySizeText by remember { mutableStateOf(state.maxHistorySizeText.toString()) } var deepSeekApiKeyText by remember { mutableStateOf(state.deepSeekApiKeyText) } var geminiApiKeyText by remember { mutableStateOf(state.geminiApiKeyText) } var algorithmUrlText by remember { mutableStateOf(state.algorithmUrlText) } var isInitFilterParams by mutableStateOf(false) var isClearCacheData by mutableStateOf(false) var selectedTab by remember { mutableStateOf(0) } var isServerOK by mutableStateOf(-1) val tabTitles = listOf( i18nState.getString("basic_settings"), i18nState.getString("api_settings"), i18nState.getString("theme_settings"), i18nState.getString("language_settings") ) AlertDialog( onDismissRequest = {}, modifier = Modifier .width(1000.dp) .height(800.dp) .background(MaterialTheme.colors.surface, RoundedCornerShape(16.dp)), text = { Box( modifier = Modifier.fillMaxSize() ) { // 主要内容区域 - 使用TabRow进行分组 Column( modifier = Modifier.fillMaxSize() ) { // 标题 Text( text = i18nState.getString("monica_general_settings"), fontSize = 20.sp, fontWeight = FontWeight.Bold, color = state.getCurrentThemeValue().primary, modifier = Modifier ) // 标签页选择器 TabRow( selectedTabIndex = selectedTab, modifier = Modifier.fillMaxWidth(), backgroundColor = MaterialTheme.colors.surface, contentColor = MaterialTheme.colors.onSurface, indicator = { tabPositions -> TabRowDefaults.Indicator( modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab]), height = 3.dp, color = state.getCurrentThemeValue().primary ) } ) { tabTitles.forEachIndexed { index, title -> Tab( selected = selectedTab == index, onClick = { selectedTab = index }, modifier = Modifier.padding(vertical = 12.dp), text = { Text( text = title, fontSize = 14.sp, fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal, color = if (selectedTab == index) { state.getCurrentThemeValue().primary } else { MaterialTheme.colors.onSurface.copy(alpha = 0.7f) } ) } ) } } // 标签页内容 Box(modifier = Modifier.fillMaxSize().padding(top = 16.dp)) { when (selectedTab) { 0 -> { // 基础设置 Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { // 输出框颜色设置 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = i18nState.getString("output_box_color_settings"), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) Column( verticalArrangement = Arrangement.spacedBy(12.dp) ) { basicTextFieldWithTitle( titleText = "R", value = rText, onValueChange = { rText = it }, modifier = Modifier.fillMaxWidth() ) basicTextFieldWithTitle( titleText = "G", value = gText, onValueChange = { gText = it }, modifier = Modifier.fillMaxWidth() ) basicTextFieldWithTitle( titleText = "B", value = bText, onValueChange = { bText = it }, modifier = Modifier.fillMaxWidth() ) } } } // 区域大小设置 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = i18nState.getString("area_size_settings"), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) basicTextFieldWithTitle( titleText = "Size", value = sizeText, onValueChange = { sizeText = it }, modifier = Modifier.fillMaxWidth() ) } } // 历史记录大小设置 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = i18nState.getString("max_history_size"), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) basicTextFieldWithTitle( titleText = "Max History Size", value = maxHistorySizeText, onValueChange = { maxHistorySizeText = it }, modifier = Modifier.fillMaxWidth() ) } } // 选项设置 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.padding(12.dp), ) { Text( text = i18nState.getString("options_settings"), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Checkbox( checked = isInitFilterParams, onCheckedChange = { isInitFilterParams = it } ) Text(i18nState.getString("init_filter_params_config")) } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Checkbox( checked = isClearCacheData, onCheckedChange = { isClearCacheData = it } ) Text(i18nState.getString("clear_cache_data")) } } } } } 1 -> { // API设置 Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { // DeepSeek API设置 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = i18nState.getString("ai_provider_deepseek") + " API Key", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) basicTextFieldWithTitle( titleText = "DeepSeek API Key", value = deepSeekApiKeyText, onValueChange = { deepSeekApiKeyText = it }, modifier = Modifier.fillMaxWidth() ) } } // Gemini API设置 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = i18nState.getString("ai_provider_gemini") + " API Key", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) basicTextFieldWithTitle( titleText = "Gemini API Key", value = geminiApiKeyText, onValueChange = { geminiApiKeyText = it }, modifier = Modifier.fillMaxWidth() ) } } // 算法URL设置 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = i18nState.getString("algorithm_service_url"), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) basicTextFieldWithTitle( titleText = "Algorithm URL", value = algorithmUrlText, onValueChange = { algorithmUrlText = it }, modifier = Modifier.fillMaxWidth() ) Row(verticalAlignment = Alignment.CenterVertically) { Text( text = i18nState.getString("enter_complete_algorithm_url"), fontSize = 12.sp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f) ) Button( onClick = { val status = try { val baseUrl = algorithmUrlText if (healthCheck(baseUrl)) { STATUS_HTTP_SERVER_OK } else { STATUS_HTTP_SERVER_FAILED } } catch (e:Exception) { STATUS_HTTP_SERVER_FAILED } isServerOK = if (status == STATUS_HTTP_SERVER_OK) { 1 } else { 0 } }, enabled = algorithmUrlText.isNotEmpty(), modifier = Modifier.padding(start = 10.dp), colors = ButtonDefaults.buttonColors(backgroundColor = state.getCurrentThemeValue().primary) ) { Text(i18nState.getString("is_the_algorithm_service_available"), color = Color.White) } if (isServerOK == 1) { Text(i18nState.getString("algorithm_service_available"), modifier = Modifier.padding(start = 10.dp), fontSize = 12.sp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)) } else if (isServerOK == 0) { Text(i18nState.getString("algorithm_service_unavailable"), modifier = Modifier.padding(start = 10.dp), fontSize = 12.sp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)) } } } } } } 2 -> { // 主题设置 Column( modifier = Modifier .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { // 当前主题显示 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Row( modifier = Modifier.padding(20.dp), ) { Text( text = i18nState.getString("current_theme"), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) Text( text = state.getCurrentThemeValue().getThemeDisplayName(), color = state.getCurrentThemeValue().primary, fontSize = 16.sp, fontWeight = FontWeight.Medium, modifier = Modifier.padding(start = 20.dp) ) } } // 主题选择 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = i18nState.getString("select_theme"), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) desktopLazyRow(modifier = Modifier.fillMaxWidth()) { Row( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { ColorTheme.entries.forEach { theme -> val isSelected = state.getCurrentThemeValue() == theme Card( modifier = Modifier .width(120.dp) .height(80.dp) .clickable { state.setTheme(theme) }, elevation = if (isSelected) 8.dp else 2.dp, shape = RoundedCornerShape(8.dp), backgroundColor = theme.background ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { // 选中状态的边框效果 if (isSelected) { Box( modifier = Modifier .fillMaxSize() .background( Color.Transparent, RoundedCornerShape(8.dp) ) .border( width = 2.dp, color = theme.primary, shape = RoundedCornerShape(8.dp) ) ) } Text( text = theme.getThemeDisplayName(), color = theme.onBackground, fontSize = 12.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium ) } } } } } } } // 重置按钮 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = i18nState.getString("theme_operations"), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) Button( onClick = { state.setTheme(ColorTheme.LIGHT) }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(backgroundColor = state.getCurrentThemeValue().primary) ) { Text(i18nState.getString("reset_to_default_theme"), color = Color.White) } } } } } 3 -> { // 语言设置 Column( modifier = Modifier .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { // 当前语言显示 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Row ( modifier = Modifier.padding(20.dp) ) { Text( text = i18nState.getString("current_language"), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) Text( text = if (LocalizationManager.currentLanguage == Language.CHINESE) i18nState.getString("chinese") else i18nState.getString("english"), fontSize = 16.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colors.onSurface, modifier = Modifier.padding(start = 20.dp) ) } } // 语言切换 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = i18nState.getString("language_switch"), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) Row( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Button( onClick = { LocalizationManager.setLanguage(Language.CHINESE) }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors( backgroundColor = if (LocalizationManager.currentLanguage == Language.CHINESE) state.getCurrentThemeValue().primary else MaterialTheme.colors.surface ) ) { Text( text = i18nState.getString("chinese"), color = if (LocalizationManager.currentLanguage == Language.CHINESE) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface ) } Button( onClick = { LocalizationManager.setLanguage(Language.ENGLISH) }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors( backgroundColor = if (LocalizationManager.currentLanguage == Language.ENGLISH) state.getCurrentThemeValue().primary else MaterialTheme.colors.surface ) ) { Text( text = "English", color = if (LocalizationManager.currentLanguage == Language.ENGLISH) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface ) } } } } // 重置语言 Card( modifier = Modifier.fillMaxWidth(), elevation = 2.dp, shape = RoundedCornerShape(12.dp), backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = i18nState.getString("language_operations"), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface ) Button( onClick = { LocalizationManager.setLanguage(Language.CHINESE) }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(backgroundColor = state.getCurrentThemeValue().primary) ) { Text(i18nState.getString("reset_to_chinese"), color = Color.White) } } } } } } } } // 底部按钮区域 - 固定在底部 Row( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .padding(16.dp) .background( MaterialTheme.colors.surface, RoundedCornerShape(8.dp) ) .padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { Spacer(modifier = Modifier.weight(1f)) Button( onClick = { state.outputBoxRText = getValidateField(block = { rText.toInt() }, failed = { val errorMsg = i18nState.getString("r_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.LOW, errorMsg, errorMsg) }) ?: return@Button state.outputBoxGText = getValidateField(block = { gText.toInt() }, failed = { val errorMsg = i18nState.getString("g_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.LOW, errorMsg, errorMsg) }) ?: return@Button state.outputBoxBText = getValidateField(block = { bText.toInt() }, failed = { val errorMsg = i18nState.getString("b_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.LOW, errorMsg, errorMsg) }) ?: return@Button state.sizeText = getValidateField(block = { sizeText.toInt() }, failed = { val errorMsg = i18nState.getString("size_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.LOW, errorMsg, errorMsg) }) ?: return@Button state.deepSeekApiKeyText = deepSeekApiKeyText state.geminiApiKeyText = geminiApiKeyText state.algorithmUrlText = if (algorithmUrlText.isNotEmpty()) { getValidateField(block = { if (algorithmUrlText.isValidUrl()) { if (algorithmUrlText.last() == '/') { algorithmUrlText } else { "$algorithmUrlText/" } } else { throw RuntimeException() } }, failed = { val errorMsg = i18nState.getString("enter_valid_url") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.LOW, errorMsg, errorMsg) }) ?: return@Button } else "" state.maxHistorySizeText = getValidateField(block = { maxHistorySizeText.toInt() }, failed = { val errorMsg = i18nState.getString("max_history_size_needs_int") showError(ErrorType.VALIDATION_ERROR, ErrorSeverity.LOW, errorMsg, errorMsg) }) ?: return@Button state.saveGeneralSettings() if (isInitFilterParams) { initFilterParamsConfig() } if (isClearCacheData) { clearData() } onClick() }, colors = ButtonDefaults.buttonColors(backgroundColor = state.getCurrentThemeValue().primary) ) { Text(i18nState.getString("update"), color = Color.White) } Button( onClick = { onClick() }, colors = ButtonDefaults.buttonColors(backgroundColor = state.getCurrentThemeValue().primary) ) { Text(i18nState.getString("close"), color = Color.White) } } } }, confirmButton = { // 空内容,按钮在text中处理 }, dismissButton = { // 空内容,按钮在text中处理 } ) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/main/MainView.kt ================================================ package cn.netdiscovery.monica.ui.main import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.preview.preview import org.koin.compose.koinInject /** * 主页面视图 - 现代化布局 * @author: Tony Shen * @date: 2025/9/8 * @version: V1.0 */ @Composable fun mainView( state: ApplicationState ) { val viewModel: MainViewModel = koinInject() viewModel.dropFile(state) Box( modifier = Modifier .fillMaxSize() .background( brush = Brush.verticalGradient( colors = listOf( MaterialTheme.colors.background, MaterialTheme.colors.surface ) ) ) ) { Row( modifier = Modifier .fillMaxSize() .padding(24.dp), // 增加整体边距 verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp) // 增加组件间距 ) { // 左侧菜单栏 SidebarView(state = state) val hasSelectedItem by remember { derivedStateOf { state.isGeneralSettings || state.isBasic || state.isColorCorrection || state.isFilter || state.isAI } } // 中间内容面板,根据是否有选中项显示 AnimatedVisibility( visible = hasSelectedItem, enter = slideInHorizontally( initialOffsetX = { -it }, animationSpec = tween(300, easing = FastOutSlowInEasing) ) + fadeIn(animationSpec = tween(300)), exit = slideOutHorizontally( targetOffsetX = { -it }, animationSpec = tween(300, easing = FastOutSlowInEasing) ) + fadeOut(animationSpec = tween(300)) ) { ContentPanel(state = state) } // 右侧预览区域 preview(state) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/main/MainViewModel.kt ================================================ package cn.netdiscovery.monica.ui.main import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.dropFileTarget import cn.netdiscovery.monica.utils.extensions.launchWithLoading import cn.netdiscovery.monica.utils.getBufferedImage import cn.netdiscovery.monica.utils.legalSuffixList import java.io.File /** * * @FileName: * cn.netdiscovery.monica.ui.main.MainViewModel * @author: Tony Shen * @date: 2024/5/24 11:03 * @version: V1.0 <描述当前版本功能> */ class MainViewModel { fun dropFile(state: ApplicationState) { state.window.contentPane.dropTarget = dropFileTarget { state.scope.launchWithLoading { val filePath = it.getOrNull(0) if (filePath != null) { val file = File(filePath) if (file.isFile && file.extension in legalSuffixList) { val image = getBufferedImage(file, state) state.rawImage = image state.currentImage = state.rawImage state.rawImageFile = file } } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/main/SidebarView.kt ================================================ package cn.netdiscovery.monica.ui.main import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.config.appVersion import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.state.ColorCorrectionStatus import cn.netdiscovery.monica.state.FilterStatus import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.ui.widget.rememberThrottledClick import org.slf4j.Logger import org.slf4j.LoggerFactory import showGeneralSettings /** * 侧边栏组件 - NavigationRail 风格 * @author: Tony Shen * @date: 2025/9/8 * @version: V1.0 */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) enum class SidebarItem( val titleKey: String, val iconPath: String, val isEnabled: (ApplicationState) -> Boolean, val onClick: (ApplicationState) -> Unit ) { BASIC_FUNCTIONS( titleKey = "basic_functions", iconPath = "images/sidebar/basic_functions.png", isEnabled = { state -> state.isBasic }, onClick = { state -> state.isBasic = !state.isBasic // 切换基础功能时,如果关闭则同时关闭压缩子模块 if (!state.isBasic) { state.isCompression = false } state.isGeneralSettings = false state.isColorCorrection = false state.isFilter = false state.isAI = false } ), COLOR_CORRECTION( titleKey = "image_color_correction", iconPath = "images/sidebar/image_color_correction.png", isEnabled = { state -> state.isColorCorrection }, onClick = { state -> state.togglePreviewWindowAndUpdateStatus(ColorCorrectionStatus) state.isGeneralSettings = false state.isBasic = false state.isCompression = false state.isFilter = false state.isAI = false } ), FILTER( titleKey = "filter_effects", iconPath = "images/sidebar/filter_effects.png", isEnabled = { state -> state.isFilter }, onClick = { state -> state.togglePreviewWindowAndUpdateStatus(FilterStatus) state.isBasic = false state.isCompression = false state.isGeneralSettings = false state.isColorCorrection = false state.isAI = false } ), AI_LAB( titleKey = "ai_laboratory", iconPath = "images/sidebar/ai_laboratory.png", isEnabled = { state -> state.isAI }, onClick = { state -> state.isAI = !state.isAI state.isBasic = false state.isCompression = false state.isGeneralSettings = false state.isColorCorrection = false state.isFilter = false } ), GENERAL_SETTINGS( titleKey = "general_settings", iconPath = "images/sidebar/settings.png", isEnabled = { state -> state.isGeneralSettings }, onClick = { state -> showGeneralSettings = true state.isBasic = false state.isCompression = false state.isColorCorrection = false state.isFilter = false state.isAI = false } ) } @Composable fun SidebarView( state: ApplicationState, modifier: Modifier = Modifier ) { val i18nState = rememberI18nState() Card( modifier = modifier .width(240.dp) .fillMaxHeight(), shape = RoundedCornerShape(16.dp), elevation = 8.dp, backgroundColor = MaterialTheme.colors.surface ) { Column( modifier = Modifier .fillMaxHeight() .padding(16.dp), verticalArrangement = Arrangement.SpaceBetween ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { // 标题 Text( text = i18nState.getString("app_name"), fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary, modifier = Modifier.padding(bottom = 16.dp) ) // 菜单项目 SidebarItem.entries.forEach { item -> SidebarMenuItem( item = item, state = state, i18nState = i18nState ) } } // 底部版本信息 Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = "版本信息", fontSize = 14.sp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontWeight = FontWeight.Medium ) Spacer(modifier = Modifier.height(4.dp)) Text( text = appVersion, fontSize = 14.sp, color = MaterialTheme.colors.primary, fontWeight = FontWeight.Bold ) } } } } @Composable private fun SidebarMenuItem( item: SidebarItem, state: ApplicationState, i18nState: cn.netdiscovery.monica.ui.i18n.I18nState ) { val isSelected = item.isEnabled(state) Card( modifier = Modifier .fillMaxWidth() .height(56.dp) .clickable( onClick = rememberThrottledClick { logger.info("点击了侧边栏项目: ${item.titleKey}") item.onClick(state) } ), shape = RoundedCornerShape(12.dp), elevation = if (isSelected) 4.dp else 0.dp, backgroundColor = if (isSelected) MaterialTheme.colors.primary.copy(alpha = 0.1f) else Color.Transparent ) { Row( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Icon( painter = painterResource(item.iconPath), contentDescription = null, modifier = Modifier.size(24.dp), tint = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.6f) ) Text( text = i18nState.getString(item.titleKey), fontSize = 14.sp, fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal, color = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.8f) ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/preview/PreviewViewModel.kt ================================================ package cn.netdiscovery.monica.ui.preview import androidx.compose.ui.geometry.Offset import cn.netdiscovery.monica.config.KEY_GENERAL_SETTINGS import cn.netdiscovery.monica.config.category.ConfigCategoryManager import cn.netdiscovery.monica.domain.GeneralSettings import cn.netdiscovery.monica.http.httpClient import cn.netdiscovery.monica.imageprocess.BufferedImages import cn.netdiscovery.monica.imageprocess.filter.blur.FastBlur2D import cn.netdiscovery.monica.imageprocess.utils.extension.* import cn.netdiscovery.monica.imageprocess.utils.writeImageFile import cn.netdiscovery.monica.imageprocess.utils.writeImageFileAsWebP import cn.netdiscovery.monica.manager.OpenCVManager import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.ImageFormatDetector import cn.netdiscovery.monica.i18n.LocalizationManager import cn.netdiscovery.monica.utils.exportImage import cn.netdiscovery.monica.utils.extensions.launchWithLoading import cn.netdiscovery.monica.utils.extensions.launchWithSuspendLoading import cn.netdiscovery.monica.utils.logger import com.safframework.kotlin.coroutines.IO import kotlinx.coroutines.launch import org.slf4j.Logger import showTopToast import java.awt.Color import java.awt.Graphics import java.awt.image.BufferedImage import java.io.File import javax.imageio.ImageIO import javax.swing.filechooser.FileNameExtensionFilter /** * * @FileName: * cn.netdiscovery.monica.ui.preview.PreviewViewModel * @author: Tony Shen * @date: 2024/5/7 20:30 * @version: V1.0 <描述当前版本功能> */ class PreviewViewModel { private val logger: Logger = logger() private val blurFilter = FastBlur2D(15) /** * 获取 GeneralSettings.size,带默认值 */ private fun getGeneralSettingsSize(): Int { val defaultSettings = GeneralSettings(255, 255, 255, 512, 50, "", "", "", "LIGHT") val settings = ConfigCategoryManager.load(KEY_GENERAL_SETTINGS, defaultSettings) return settings.size } fun loadUrl(picUrl:String, state: ApplicationState) { logger.info("load picUrl: $picUrl") state.scope.launchWithSuspendLoading { try { val inputStream = httpClient.get(picUrl).body?.byteStream() val bufferedImage = ImageIO.read(inputStream) state.rawImage = bufferedImage state.currentImage = state.rawImage } catch (_: Exception) { } } } fun recoverImage(state: ApplicationState) { state.currentImage = state.rawImage state.clearQueue() } fun getLastImage(state: ApplicationState) { state.getLastImage()?.let { state.currentImage = it } } fun blur(width:Int, height:Int,offset: Offset,state: ApplicationState) { state.scope.launch(IO) { val bufferedImage = state.currentImage!! val srcWidth = bufferedImage.width val srcHeight = bufferedImage.height val xScale = (srcWidth.toFloat()/width) val yScale = (srcHeight.toFloat()/height) val size = getGeneralSettingsSize() // 打码区域左上角x坐标 val x = (offset.x*xScale).toInt() // 打码区域左上角y坐标 val y = (offset.y*yScale).toInt() // 打码区域宽度 val width = (size*xScale).toInt() // 打码区域高度 val height = (size*yScale).toInt() var tempImage = bufferedImage.subImage(x,y,width,height) tempImage = blurFilter.transform(tempImage) val outputImage = BufferedImages.create(srcWidth, srcHeight, state.currentImage!!.type) val graphics2D = outputImage.createGraphics() graphics2D.drawImage(bufferedImage, 0, 0, null) graphics2D.drawImage(tempImage, x, y, width, height, null) graphics2D.dispose() state.addQueue(state.currentImage!!) state.currentImage = outputImage } } fun mosaic(width:Int, height:Int,offset: Offset,state: ApplicationState) { state.scope.launch(IO) { val bufferedImage = state.currentImage!! val srcWidth = bufferedImage.width val srcHeight = bufferedImage.height val xScale = (srcWidth.toFloat()/width) val yScale = (srcHeight.toFloat()/height) val size = getGeneralSettingsSize() // 创建与输入图像相同大小的新图像 val outputImage = BufferedImages.create(srcWidth, srcHeight, state.currentImage!!.type) // 创建画笔 val graphics: Graphics = outputImage.graphics // 将原始图像绘制到新图像中 graphics.drawImage(bufferedImage, 0, 0, null) // 打码区域左上角x坐标 val x = (offset.x*xScale).toInt() // 打码区域左上角y坐标 val y = (offset.y*yScale).toInt() // 打码区域宽度 val width = (size*xScale).toInt() // 打码区域高度 val height = (size*yScale).toInt() val mosaicSize = 40 var xcount = 0 // 方向绘制个数 var ycount = 0 // y方向绘制个数 xcount = if (width % mosaicSize === 0) { width / mosaicSize } else { width / mosaicSize + 1 } ycount = if (height % mosaicSize === 0) { height / mosaicSize } else { height / mosaicSize + 1 } var xTmp = x var yTmp = y for (i in 0 until xcount) { for (j in 0 until ycount) { //马赛克矩形格大小 var mwidth = mosaicSize var mheight = mosaicSize if (i == xcount - 1) { //横向最后一个比较特殊,可能不够一个size mwidth = width - xTmp } if (j == ycount - 1) { //同理 mheight = height - yTmp } //矩形颜色取中心像素点RGB值 var centerX = xTmp var centerY = yTmp centerX += if (mwidth % 2 == 0) { mwidth / 2 } else { (mwidth - 1) / 2 } centerY += if (mheight % 2 == 0) { mheight / 2 } else { (mheight - 1) / 2 } val color: Color = Color(bufferedImage.getRGB(centerX, centerY)) graphics.setColor(color) graphics.fillRect(xTmp, yTmp, mwidth, mheight) yTmp += mosaicSize // 计算下一个矩形的y坐标 } yTmp = y // 还原y坐标 xTmp += mosaicSize // 计算x坐标 } // 释放资源 graphics.dispose() state.addQueue(state.currentImage!!) state.currentImage = outputImage } } fun flip(state: ApplicationState) { state.currentImage?.let { state.addQueue(it) state.currentImage = it.flipHorizontally() } } fun rotate(state: ApplicationState) { state.currentImage?.let { state.addQueue(it) state.currentImage = it.rotate(-90.0) } } fun resize(width:Int, height:Int, state: ApplicationState) { state.currentImage?.let { if (width == it.width && height == it.height) { return@let } val resizedImage = it.resize(width, height) state.addQueue(it) state.currentImage = resizedImage } } fun shearing(x:Float, y:Float, state: ApplicationState) { state.currentImage?.let { if (x == 0f && y == 0f) { return@let } state.scope.launchWithLoading { OpenCVManager.invokeCV(state, action = { byteArray -> ImageProcess.shearing(byteArray, x, y) }, failure = { e -> logger.error("shearing is failed", e) }) } } } fun saveImage(state: ApplicationState) { state.currentImage?.let { exportImage { chooser -> val selectedFile = chooser.selectedFile val selectedFilter = chooser.fileFilter as FileNameExtensionFilter val format = selectedFilter.extensions[0] // "png" or "jpg" val outputFile = if (selectedFile.name.lowercase().endsWith(".${format}")) { selectedFile } else { File(selectedFile.parent, "${selectedFile.name}.${format}") } val nativePtr = state.nativeImageInfo?.nativePtr val nativeImage = if (nativePtr!=null && nativePtr!=0L) { ImageProcess.getNativeImage(nativePtr) } else { null } val savedImage = if (nativeImage!=null) { BufferedImages.toBufferedImage(nativeImage.pixels, nativeImage.width, nativeImage.height, BufferedImage.TYPE_INT_ARGB) } else { state.currentImage!! } val b = when(format) { "jpg" -> { val finalImage = if (state.rawImageFile==null) state.currentImage!! else { if (ImageFormatDetector.getImageFormat(state.rawImageFile!!) != "jpeg") { state.currentImage!!.convertToRGB() } else state.currentImage!! } writeImageFile(finalImage, outputFile.absolutePath, format) } "png" -> { writeImageFile(savedImage, outputFile.absolutePath, format) } "webp" -> { writeImageFileAsWebP(savedImage, outputFile.absolutePath) } else -> { writeImageFile(savedImage, outputFile.absolutePath, format) } } if (b) showTopToast(LocalizationManager.getString("image_save_success")) else showTopToast(LocalizationManager.getString("image_save_failed")) } } } fun clearImage(state: ApplicationState) { state.clearImage() } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/preview/PreviewViewt.kt ================================================ package cn.netdiscovery.monica.ui.preview import androidx.compose.foundation.Image import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.toPainter import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.state.BlurStatus import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.state.MosaicStatus import cn.netdiscovery.monica.state.ZoomPreviewStatus import cn.netdiscovery.monica.ui.widget.toolTipButton import cn.netdiscovery.monica.utils.chooseImage import cn.netdiscovery.monica.utils.getBufferedImage import org.koin.compose.koinInject import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.ui.PreviewView * @author: Tony Shen * @date: 2024/4/26 11:09 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) @OptIn(ExperimentalMaterialApi::class) @Composable fun preview( state: ApplicationState, ) { val viewModel: PreviewViewModel = koinInject() Card( shape = RoundedCornerShape(16.dp), elevation = 4.dp, onClick = { chooseImage(state) { file -> val image = getBufferedImage(file, state) state.rawImage = image state.currentImage = state.rawImage state.rawImageFile = file } }, enabled = state.rawImage == null ) { if (state.rawImage == null) { chooseImage() } else { previewImage(state,viewModel) } } } @Composable private fun previewImage(state: ApplicationState, viewModel: PreviewViewModel) { val i18nState = rememberI18nState() if (state.currentImage == null) return Column( modifier = Modifier.fillMaxSize() ) { Column( modifier = Modifier.fillMaxSize().weight(9f), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Image( painter = state.currentImage!!.toPainter(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier .pointerInput(Unit) { val width = this.size.width val height = this.size.height detectTapGestures( onPress = { if (state.currentStatus == MosaicStatus) { viewModel.mosaic(width, height, it, state) } else if (state.currentStatus == BlurStatus) { viewModel.blur(width,height, it, state) } }) } .drawWithContent { drawContent() }) } Row ( modifier = Modifier.fillMaxSize().weight(1f), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { // 恢复最初 toolTipButton(text = i18nState.getString("restore_original"), painter = painterResource("images/preview/initial_picture.png"), iconModifier = Modifier.size(30.dp), onClick = { viewModel.recoverImage(state) }) // 上一步 toolTipButton(text = i18nState.getString("previous_step"), painter = painterResource("images/preview/reduction.png"), onClick = { viewModel.getLastImage(state) }) // 放大预览 toolTipButton(text = i18nState.getString("enlarge_preview"), painter = painterResource("images/preview/zoom.png"), onClick = { state.togglePreviewWindowAndUpdateStatus(ZoomPreviewStatus) }) // 保存 toolTipButton(text = i18nState.getString("save"), painter = painterResource("images/preview/save.png"), onClick = { viewModel.saveImage(state) }) // 删除 toolTipButton(text = i18nState.getString("delete"), painter = painterResource("images/preview/delete.png"), onClick = { viewModel.clearImage(state) }) } } } @Composable private fun chooseImage() { val i18nState = rememberI18nState() Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = i18nState.getString("click_to_select_image_or_drag"), textAlign = TextAlign.Center ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/screenshot/SwingScreenshotAreaSelector.kt ================================================ package cn.netdiscovery.monica.ui.screenshot import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.captureRegion import cn.netdiscovery.monica.utils.loadScreenshotToState import org.slf4j.Logger import org.slf4j.LoggerFactory import java.awt.* import java.awt.event.* import javax.swing.JFrame import javax.swing.JPanel import javax.swing.SwingUtilities /** * 基于 Swing 的区域选择截图工具 * 在 macOS 上更可靠地实现全屏透明窗口 * * @author: Tony Shen * @date: 2025/12/03 * @version: V1.0 */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) /** * 显示 Swing 区域选择截图窗口 */ fun showSwingScreenshotAreaSelector( state: ApplicationState, onDismiss: () -> Unit ) { SwingUtilities.invokeLater { ScreenshotAreaFrame(state, onDismiss) } } private class ScreenshotAreaFrame( private val state: ApplicationState, private val onDismiss: () -> Unit ) : JFrame() { private var startPoint: Point? = null private var endPoint: Point? = null private var isSelecting = false private val selectionPanel = object : JPanel() { override fun paintComponent(g: Graphics) { super.paintComponent(g) if (isSelecting && startPoint != null && endPoint != null) { val g2d = g as Graphics2D g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) val start = startPoint!! val end = endPoint!! // 规范化坐标 val left = minOf(start.x, end.x) val top = minOf(start.y, end.y) val right = maxOf(start.x, end.x) val bottom = maxOf(start.y, end.y) val width = right - left val height = bottom - top // 绘制半透明遮罩(选择区域外) g2d.color = Color(0, 0, 0, 128) // 半透明黑色 // 上方 if (top > 0) { g2d.fillRect(0, 0, size.width, top) } // 下方 if (bottom < size.height) { g2d.fillRect(0, bottom, size.width, size.height - bottom) } // 左侧 if (left > 0) { g2d.fillRect(0, top, left, height) } // 右侧 if (right < size.width) { g2d.fillRect(right, top, size.width - right, height) } // 绘制选择框边框 g2d.color = Color.WHITE val oldStroke = g2d.stroke g2d.stroke = BasicStroke(2f) g2d.drawRect(left, top, width, height) g2d.stroke = oldStroke // 绘制尺寸信息 val infoText = "${width} × ${height}" g2d.color = Color.WHITE g2d.font = Font(Font.SANS_SERIF, Font.PLAIN, 14) val fontMetrics = g2d.fontMetrics val textWidth = fontMetrics.stringWidth(infoText) val textHeight = fontMetrics.height val textX = left + 5 val textY = if (top - textHeight - 5 > 0) { top - 5 } else { bottom + textHeight + 5 } // 绘制文本背景(提高可读性) g2d.color = Color(0, 0, 0, 180) g2d.fillRect(textX - 2, textY - textHeight - 2, textWidth + 4, textHeight + 4) // 绘制文本 g2d.color = Color.WHITE g2d.drawString(infoText, textX, textY) } else { // 没有选择时,绘制全屏半透明遮罩 g.color = Color(0, 0, 0, 76) // 30% 透明度 g.fillRect(0, 0, size.width, size.height) } } } init { // 隐藏主窗口 logger.info("区域选择器打开,隐藏主窗口") state.window.isVisible = false // 设置窗口属性 isUndecorated = true background = Color(0, 0, 0, 0) // 完全透明 isAlwaysOnTop = true isResizable = false // 获取屏幕尺寸 val screenSize = Toolkit.getDefaultToolkit().screenSize val graphicsConfig = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration // 设置窗口大小和位置 bounds = graphicsConfig.bounds // 设置内容面板 contentPane = selectionPanel (contentPane as? JPanel)?.apply { background = Color(0, 0, 0, 0) isOpaque = false } // 添加鼠标监听器 val mouseAdapter = object : MouseAdapter() { override fun mousePressed(e: MouseEvent) { startPoint = e.point endPoint = e.point isSelecting = true selectionPanel.repaint() } override fun mouseDragged(e: MouseEvent) { endPoint = e.point selectionPanel.repaint() } override fun mouseReleased(e: MouseEvent) { isSelecting = false val start = startPoint val end = endPoint if (start != null && end != null) { // 规范化坐标 val x = minOf(start.x, end.x).coerceAtLeast(0) val y = minOf(start.y, end.y).coerceAtLeast(0) val width = (maxOf(start.x, end.x) - minOf(start.x, end.x)).coerceAtLeast(1) val height = (maxOf(start.y, end.y) - minOf(start.y, end.y)).coerceAtLeast(1) logger.info("选择区域: x=$x, y=$y, width=$width, height=$height") // 在后台线程执行截图 Thread { try { val screenshot = captureRegion(x, y, width, height) SwingUtilities.invokeLater { dispose() // 关闭窗口 state.window.isVisible = true // 恢复主窗口 if (screenshot != null) { logger.info("截图成功,尺寸: ${screenshot.width}x${screenshot.height}") loadScreenshotToState(state, screenshot) } else { logger.error("截图失败") } onDismiss() } } catch (e: Exception) { logger.error("截图异常", e) SwingUtilities.invokeLater { dispose() state.window.isVisible = true onDismiss() } } }.start() } else { // 没有选择区域,直接关闭 dispose() state.window.isVisible = true onDismiss() } startPoint = null endPoint = null } } selectionPanel.addMouseListener(mouseAdapter) selectionPanel.addMouseMotionListener(mouseAdapter) // ESC 键关闭 val keyAdapter = object : KeyAdapter() { override fun keyPressed(e: KeyEvent) { if (e.keyCode == KeyEvent.VK_ESCAPE) { dispose() state.window.isVisible = true onDismiss() } } } addKeyListener(keyAdapter) selectionPanel.isFocusable = true selectionPanel.requestFocus() // 设置窗口关闭监听 defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE addWindowListener(object : WindowAdapter() { override fun windowClosed(e: WindowEvent?) { state.window.isVisible = true onDismiss() } }) // 显示窗口 isVisible = true toFront() selectionPanel.requestFocus() logger.info("Swing 区域选择窗口已显示,大小: ${bounds.width}x${bounds.height}") } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/showimage/ShowImageView.kt ================================================ package cn.netdiscovery.monica.ui.showimage import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.ui.i18n.rememberI18nState import cn.netdiscovery.monica.utils.extensions.to2fStr /** * * @FileName: * cn.netdiscovery.monica.ui.showimage.ShowImgView * @author: Tony Shen * @date: 2024/4/26 22:18 * @version: V1.0 <描述当前版本功能> */ @Composable fun showImage( state: ApplicationState ) { val i18nState = rememberI18nState() var angle by remember { mutableStateOf(0f) } // 旋转角度 var scale by remember { mutableStateOf(1f) } // 缩放 var offsetX by remember { mutableStateOf(0f) } // x偏移 var offsetY by remember { mutableStateOf(0f) } // y偏移 var matrix by remember { mutableStateOf(Matrix()) } // 矩阵 val image = state.currentImage!!.toComposeImageBitmap() Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Image( bitmap = image, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier .fillMaxSize() .graphicsLayer { scaleX = scale scaleY = scale rotationZ = angle translationX = offsetX translationY = offsetY } .pointerInput(Unit) { detectTransformGestures { centroid, pan, zoom, rotation -> angle += rotation scale *= zoom matrix.translate(pan.x, pan.y) matrix.rotateZ(rotation) matrix.scale(zoom, zoom) matrix = Matrix(matrix.values) offsetX = matrix.values[Matrix.TranslateX] offsetY = matrix.values[Matrix.TranslateY] } } ) Row (modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp)){ Column( Modifier.padding(end = 10.dp), verticalArrangement = Arrangement.Center ) { OutlinedButton( onClick = { angle = 0f scale = 1f offsetX = 0f offsetY = 0f matrix = Matrix() }, ) { Text(i18nState.getString("restore")) } } Column( verticalArrangement = Arrangement.Center ) { Text( text = scale.to2fStr(), color = Color.Unspecified, modifier = Modifier.align(Alignment.CenterHorizontally) ) verticalSlider( value = scale, onValueChange = { scale = it }, modifier = Modifier .width(200.dp) .height(50.dp) .background(Color(0xffdedede)), valueRange = 0.1f..5f ) } } } } @Composable fun verticalSlider( value: Float, onValueChange: (Float) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, valueRange: ClosedFloatingPointRange = 0f..1f, /*@IntRange(from = 0)*/ steps: Int = 0, onValueChangeFinished: (() -> Unit)? = null, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, colors: SliderColors = SliderDefaults.colors() ){ val focusRequester = remember { FocusRequester() } Slider( colors = colors, interactionSource = interactionSource, onValueChangeFinished = onValueChangeFinished, steps = steps, valueRange = valueRange, enabled = enabled, value = value, onValueChange = onValueChange, modifier = Modifier .focusRequester(focusRequester) .graphicsLayer { rotationZ = 270f transformOrigin = TransformOrigin(0f, 0f) } .layout { measurable, constraints -> val placeable = measurable.measure( Constraints( minWidth = constraints.minHeight, maxWidth = constraints.maxHeight, minHeight = constraints.minWidth, maxHeight = constraints.maxHeight, ) ) layout(placeable.height, placeable.width) { placeable.place(-placeable.width, 0) } } .then(modifier) ) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/theme/ColorTheme.kt ================================================ package cn.netdiscovery.monica.ui.theme import androidx.compose.ui.graphics.Color import cn.netdiscovery.monica.i18n.LocalizationManager import cn.netdiscovery.monica.i18n.Language /** * 颜色主题枚举 * @author: Tony Shen * @date: 2025/9/8 * @version: V1.0 */ enum class ColorTheme( val displayName: String, val primary: Color, val primaryVariant: Color, val secondary: Color, val secondaryVariant: Color, val background: Color, val surface: Color, val error: Color, val onPrimary: Color, val onSecondary: Color, val onBackground: Color, val onSurface: Color, val onError: Color ) { LIGHT( displayName = "浅色主题", primary = Color(0xFF2196F3), primaryVariant = Color(0xFF1976D2), secondary = Color(0xFF03DAC6), secondaryVariant = Color(0xFF018786), background = Color(0xFFF5F5F5), surface = Color(0xFFFFFFFF), error = Color(0xFFB00020), onPrimary = Color(0xFFFFFFFF), onSecondary = Color(0xFF000000), onBackground = Color(0xFF000000), onSurface = Color(0xFF000000), onError = Color(0xFFFFFFFF) ), DARK( displayName = "深色主题", primary = Color(0xFF90CAF9), primaryVariant = Color(0xFF42A5F5), secondary = Color(0xFF03DAC6), secondaryVariant = Color(0xFF018786), background = Color(0xFF1A1A1A), // 稍微亮一点的深色 surface = Color(0xFF2D2D2D), // 更亮的表面色 error = Color(0xFFCF6679), onPrimary = Color(0xFF000000), onSecondary = Color(0xFF000000), onBackground = Color(0xFFE0E0E0), // 更亮的文字色 onSurface = Color(0xFFE0E0E0), // 更亮的文字色 onError = Color(0xFF000000) ), BLUE( displayName = "蓝色主题", primary = Color(0xFF1976D2), primaryVariant = Color(0xFF0D47A1), secondary = Color(0xFF03DAC6), secondaryVariant = Color(0xFF018786), background = Color(0xFFE3F2FD), surface = Color(0xFFFFFFFF), error = Color(0xFFB00020), onPrimary = Color(0xFFFFFFFF), onSecondary = Color(0xFF000000), onBackground = Color(0xFF000000), onSurface = Color(0xFF000000), onError = Color(0xFFFFFFFF) ), GREEN( displayName = "绿色主题", primary = Color(0xFF388E3C), primaryVariant = Color(0xFF1B5E20), secondary = Color(0xFF03DAC6), secondaryVariant = Color(0xFF018786), background = Color(0xFFE8F5E8), surface = Color(0xFFFFFFFF), error = Color(0xFFB00020), onPrimary = Color(0xFFFFFFFF), onSecondary = Color(0xFF000000), onBackground = Color(0xFF000000), onSurface = Color(0xFF000000), onError = Color(0xFFFFFFFF) ), PURPLE( displayName = "紫色主题", primary = Color(0xFF7B1FA2), primaryVariant = Color(0xFF4A148C), secondary = Color(0xFF03DAC6), secondaryVariant = Color(0xFF018786), background = Color(0xFFF3E5F5), surface = Color(0xFFFFFFFF), error = Color(0xFFB00020), onPrimary = Color(0xFFFFFFFF), onSecondary = Color(0xFF000000), onBackground = Color(0xFF000000), onSurface = Color(0xFF000000), onError = Color(0xFFFFFFFF) ), ORANGE( displayName = "橙色主题", primary = Color(0xFFF57C00), primaryVariant = Color(0xFFE65100), secondary = Color(0xFF03DAC6), secondaryVariant = Color(0xFF018786), background = Color(0xFFFFF3E0), surface = Color(0xFFFFFFFF), error = Color(0xFFB00020), onPrimary = Color(0xFFFFFFFF), onSecondary = Color(0xFF000000), onBackground = Color(0xFF000000), onSurface = Color(0xFF000000), onError = Color(0xFFFFFFFF) ), PINK( displayName = "粉色主题", primary = Color(0xFFE91E63), primaryVariant = Color(0xFFC2185B), secondary = Color(0xFFF06292), secondaryVariant = Color(0xFFE91E63), background = Color(0xFFFCE4EC), surface = Color(0xFFF8BBD9), error = Color(0xFFB00020), onPrimary = Color(0xFFFFFFFF), onSecondary = Color(0xFF000000), onBackground = Color(0xFF000000), onSurface = Color(0xFF000000), onError = Color(0xFFFFFFFF) ); /** * 获取主题的显示名称 */ fun getThemeDisplayName(): String { return when (this) { LIGHT -> if (LocalizationManager.currentLanguage == Language.CHINESE) "浅色主题" else "Light Theme" DARK -> if (LocalizationManager.currentLanguage == Language.CHINESE) "深色主题" else "Dark Theme" BLUE -> if (LocalizationManager.currentLanguage == Language.CHINESE) "蓝色主题" else "Blue Theme" GREEN -> if (LocalizationManager.currentLanguage == Language.CHINESE) "绿色主题" else "Green Theme" PURPLE -> if (LocalizationManager.currentLanguage == Language.CHINESE) "紫色主题" else "Purple Theme" ORANGE -> if (LocalizationManager.currentLanguage == Language.CHINESE) "橙色主题" else "Orange Theme" PINK -> if (LocalizationManager.currentLanguage == Language.CHINESE) "粉色主题" else "Pink Theme" } } /** * 获取主题的唯一标识符 */ fun getThemeId(): String = name } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/theme/ThemeManager.kt ================================================ package cn.netdiscovery.monica.ui.theme import androidx.compose.material.Colors import androidx.compose.material.MaterialTheme import androidx.compose.material.darkColors import androidx.compose.material.lightColors import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import org.slf4j.Logger import org.slf4j.LoggerFactory /** * 主题管理器 * @author: Tony Shen * @date: 2025/9/8 * @version: V1.0 */ object ThemeManager { private val logger: Logger = LoggerFactory.getLogger(ThemeManager::class.java) // 移除独立的状态管理,改为从外部获取 private var _currentTheme: ColorTheme? = null /** * 设置当前主题(从ApplicationState调用) */ fun setCurrentTheme(theme: ColorTheme) { logger.info("切换主题: ${theme.displayName}") _currentTheme = theme } /** * 获取当前主题 */ fun getCurrentTheme(): ColorTheme { return _currentTheme ?: ColorTheme.LIGHT } /** * 根据主题获取 Material Colors * 根据背景亮度自动选择使用 lightColors 或 darkColors */ fun getMaterialColors(theme: ColorTheme = getCurrentTheme()): Colors { // 计算背景色的亮度来判断是否为深色主题 val isDarkTheme = isDarkBackground(theme.background) return if (isDarkTheme) { darkColors( primary = theme.primary, primaryVariant = theme.primaryVariant, secondary = theme.secondary, secondaryVariant = theme.secondaryVariant, background = theme.background, surface = theme.surface, error = theme.error, onPrimary = theme.onPrimary, onSecondary = theme.onSecondary, onBackground = theme.onBackground, onSurface = theme.onSurface, onError = theme.onError ) } else { lightColors( primary = theme.primary, primaryVariant = theme.primaryVariant, secondary = theme.secondary, secondaryVariant = theme.secondaryVariant, background = theme.background, surface = theme.surface, error = theme.error, onPrimary = theme.onPrimary, onSecondary = theme.onSecondary, onBackground = theme.onBackground, onSurface = theme.onSurface, onError = theme.onError ) } } /** * 判断背景色是否为深色 * 使用相对亮度公式:0.299*R + 0.587*G + 0.114*B */ private fun isDarkBackground(backgroundColor: Color): Boolean { val luminance = 0.299f * backgroundColor.red + 0.587f * backgroundColor.green + 0.114f * backgroundColor.blue return luminance < 0.5f // 亮度小于0.5认为是深色背景 } /** * 获取所有可用主题 */ fun getAllThemes(): List { return ColorTheme.values().toList() } /** * 根据 ID 获取主题 */ fun getThemeById(id: String): ColorTheme? { return try { ColorTheme.valueOf(id) } catch (e: IllegalArgumentException) { logger.warn("未找到主题: $id") null } } /** * 重置为默认主题 */ fun resetToDefault() { logger.info("重置为默认主题") setCurrentTheme(ColorTheme.LIGHT) } } /** * 主题状态的可组合函数 */ @Composable fun rememberThemeState(): ColorTheme { return ThemeManager.getCurrentTheme() } /** * 主题切换的可组合函数 */ @Composable fun setTheme(theme: ColorTheme) { ThemeManager.setCurrentTheme(theme) } /** * 自定义 MaterialTheme 包装器 */ @Composable fun CustomMaterialTheme( theme: ColorTheme = ThemeManager.getCurrentTheme(), content: @Composable () -> Unit ) { val colors = ThemeManager.getMaterialColors(theme) MaterialTheme( colors = colors, content = content ) } /** * 主题状态提供者 */ val LocalThemeState = staticCompositionLocalOf { ColorTheme.LIGHT } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/Buttons.kt ================================================ package cn.netdiscovery.monica.ui.widget import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.TooltipPlacement import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.utils.Action import org.slf4j.Logger import org.slf4j.LoggerFactory import cn.netdiscovery.monica.i18n.LocalizationManager /** * * @FileName: * cn.netdiscovery.monica.ui.widget.Buttons * @author: Tony Shen * @date: 2024/5/11 10:46 * @version: V1.0 <描述当前版本功能> */ val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) const val VIEW_CLICK_INTERVAL_TIME = 300 // View 的 click 方法的两次点击间隔时间,减少到300ms提高响应性 /** * 可复用的点击节流函数,支持状态隔离、高精度时间、加载状态拦截与过滤函数。 */ @Composable fun rememberThrottledClick( intervalMs: Int = VIEW_CLICK_INTERVAL_TIME, isLoading: Boolean = false, filter: () -> Boolean = { true }, onClick: Action ): Action { // 使用 nanoTime,避免 system time 被修改时导致节流失效 var lastClickNanoTime by remember { mutableStateOf(0L) } val intervalNs = intervalMs * 1_000_000L return { val now = System.nanoTime() val elapsed = now - lastClickNanoTime if (elapsed >= intervalNs && !isLoading && filter()) { lastClickNanoTime = now onClick() } } } @OptIn(ExperimentalFoundationApi::class) @Composable fun toolTipButton( text:String, painter: Painter, buttonModifier: Modifier = Modifier.padding(5.dp), iconModifier: Modifier = Modifier.size(36.dp), enable: ()-> Boolean = { true }, onClick: Action, ) { TooltipArea( tooltip = { // composable tooltip content Surface( modifier = Modifier.shadow(4.dp), color = Color(255, 255, 210), shape = RoundedCornerShape(4.dp) ) { Text( text = text, modifier = Modifier.padding(10.dp) ) } }, delayMillis = 600, // in milliseconds tooltipPlacement = TooltipPlacement.CursorPoint( alignment = Alignment.BottomEnd, offset = DpOffset((-16).dp, 0.dp) ) ) { IconButton( modifier = buttonModifier.padding(4.dp), // 增加额外的padding扩大点击区域 onClick = rememberThrottledClick { // 防止重复点击,300ms内只有1次点击是有效的 logger.info("点击了 $text 按钮") onClick() }, enabled = enable() ) { Icon( painter = painter, contentDescription = text, modifier = iconModifier ) } } } @Composable fun confirmButton(enabled:Boolean, text:String = LocalizationManager.getString("confirm"), modifier:Modifier = Modifier, onClick: () -> Unit) { Button( modifier = modifier, onClick = rememberThrottledClick { onClick.invoke() }, enabled = enabled ) { Text(text = text, color = if (enabled) Color.Unspecified else Color.LightGray) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/Checkboxs.kt ================================================ package cn.netdiscovery.monica.ui.widget import androidx.compose.foundation.layout.Row import androidx.compose.material.Checkbox import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.TextUnit import androidx.compose.material.MaterialTheme /** * * @FileName: * cn.netdiscovery.monica.ui.widget.Checkboxs * @author: Tony Shen * @date: 2024/10/28 13:50 * @version: V1.0 <描述当前版本功能> */ @Composable fun checkBoxWithTitle( text: String, modifier: Modifier = Modifier, textModifier: Modifier = Modifier, color: Color = Color.Unspecified, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, fontSize: TextUnit = MaterialTheme.typography.body1.fontSize, fontWeight: FontWeight? = null ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically ) { Checkbox( checked = checked, onCheckedChange = { onCheckedChange?.invoke(it) } ) Text( text = text, modifier = textModifier, color = color, fontSize = fontSize, fontWeight = fontWeight ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/Divider.kt ================================================ package cn.netdiscovery.monica.ui.widget import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp /** * * @FileName: * cn.netdiscovery.monica.ui.widget.Divider * @author: Tony Shen * @date: 2024/10/2 22:13 * @version: V1.0 <描述当前版本功能> */ @Composable fun divider() { Row { Spacer(modifier = Modifier.padding(top = 10.dp, bottom = 10.dp).height(1.dp).weight(1.0f).background(color = Color.LightGray)) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/LazyRow.kt ================================================ package cn.netdiscovery.monica.ui.widget import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import kotlinx.coroutines.launch /** * * @FileName: * cn.netdiscovery.monica.ui.widget.LazyRow * @author: Tony Shen * @date: 2024/7/13 22:27 * @version: V1.0 <描述当前版本功能> */ @Composable fun desktopLazyRow(modifier:Modifier = Modifier, content: @Composable () -> Unit) { val scrollState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() LazyRow( state = scrollState, modifier = modifier .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> coroutineScope.launch { scrollState.scrollBy(-delta) } }, ) ) { item { content.invoke() } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/PageLifecycle.kt ================================================ package cn.netdiscovery.monica.ui.widget import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.DisposableEffect /** * * @FileName: * cn.netdiscovery.monica.ui.widget.PageLifecycle * @author: Tony Shen * @date: 2025/6/17 15:45 * @version: V1.0 <描述当前版本功能> */ /** * 页面生命周期钩子,用于页面级别的生命周期管理。在页面进入时执行初始化逻辑,在页面移除时释放资源。 * * @param onInit 页面进入时的初始化逻辑(支持 suspend 函数) * @param onDisposeEffect 页面被移除时的清理逻辑(同步函数) */ @Composable fun PageLifecycle( onInit: suspend () -> Unit, onDisposeEffect: () -> Unit ) { // 页面进入时执行(支持挂起函数) LaunchedEffect(Unit) { onInit() } // 页面移除时执行(释放资源、取消监听等) DisposableEffect(Unit) { onDispose { onDisposeEffect() } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/RightSideMenuBar.kt ================================================ package cn.netdiscovery.monica.ui.widget import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp /** * * @FileName: * cn.netdiscovery.monica.ui.widget.RightSideMenuBar * @author: Tony Shen * @date: 2024/10/5 14:22 * @version: V1.0 <描述当前版本功能> */ @Composable fun rightSideMenuBar(modifier: Modifier, backgroundColor:Color = Color.LightGray, percent:Int = 15, content: @Composable ColumnScope.() -> Unit) { Row(modifier = modifier .padding(start = 10.dp, end = 10.dp) .background(color = backgroundColor, shape = RoundedCornerShape(percent))) { Column( Modifier.padding(start = 10.dp, end = 10.dp, top = 20.dp, bottom = 20.dp), verticalArrangement = Arrangement.Center ) { content.invoke(this) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/TextFields.kt ================================================ package cn.netdiscovery.monica.ui.widget import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** * * @FileName: * cn.netdiscovery.monica.ui.widget.TextFields * @author: Tony Shen * @date: 2024/10/17 11:17 * @version: V1.0 <描述当前版本功能> */ @Composable fun basicTextFieldWithTitle( titleText:String, value: String, modifier:Modifier = Modifier, textModifier:Modifier = Modifier, width: Dp = 120.dp, onValueChange: (String) -> Unit) { Row { Text(text = titleText, modifier = textModifier) BasicTextField( value = value, onValueChange = onValueChange, keyboardOptions = KeyboardOptions.Default, keyboardActions = KeyboardActions.Default, cursorBrush = SolidColor(Color.Gray), singleLine = true, modifier = modifier.padding(start = 10.dp, end = 10.dp).width(width).background(Color.LightGray.copy(alpha = 0.5f), shape = RoundedCornerShape(3.dp)).height(20.dp), textStyle = TextStyle(Color.Black, fontSize = 12.sp) ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/ThreeBallLoading.kt ================================================ package cn.netdiscovery.monica.ui.widget import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.config.height import cn.netdiscovery.monica.config.loadingWidth import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlin.math.cos import kotlin.math.sin /** * * @FileName: * cn.netdiscovery.monica.ui.ThreeBallLoading * @author: Tony Shen * @date: 2024/4/28 17:45 * @version: V1.0 <描述当前版本功能> */ @Composable fun showLoading() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { ThreeBallLoading( modifier = Modifier .width(loadingWidth) .height(height) .background( color = Color.Transparent, shape = RoundedCornerShape(16.dp) ) .padding(20.dp) ) } } @Composable fun ThreeBallLoading(modifier: Modifier) { val width = remember { mutableStateOf(800f) } val height = remember { mutableStateOf(800f) } val centerX = width.value / 2 val centerY = height.value / 2 val anglist = Array(3) { 120f * it } val ballradius = 20f val colorList = listOf( Color(0xffFF1D1D), Color(0xff0055FF), Color(0xff43B988), ) val transition = rememberInfiniteTransition() val radiusDiff = transition.animateFloat( ballradius / 2, ballradius * 4, animationSpec = InfiniteRepeatableSpec( tween(durationMillis = 500, easing = LinearEasing), repeatMode = RepeatMode.Reverse ) ) val diff = remember { mutableStateOf(0f) } LaunchedEffect(true) { flow { while (true) { emit(1) delay(1000) } }.collect { diff.value += 90f } } val angleDiff = animateFloatAsState( diff.value, TweenSpec( durationMillis = 500, easing = LinearEasing ) ) Canvas( modifier = modifier.padding(10.dp) ) { width.value = size.width height.value = size.height for (index in anglist.indices) { drawCircle( colorList[index], radius = 20f, center = Offset( pointX(radiusDiff.value, centerX, anglist[index] + angleDiff.value), pointY(radiusDiff.value, centerY, anglist[index] + angleDiff.value) ) ) } } } private fun pointX(radius: Float, centerX: Float, angle: Float): Float { val res = Math.toRadians(angle.toDouble()) return centerX - cos(res).toFloat() * (radius) } private fun pointY(radius: Float, centerY: Float, angle: Float): Float { val res = Math.toRadians(angle.toDouble()) return centerY - sin(res).toFloat() * (radius) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/Title.kt ================================================ package cn.netdiscovery.monica.ui.widget import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.TextUnit import cn.netdiscovery.monica.config.subTitleTextSize import cn.netdiscovery.monica.config.titleTextSize /** * * @FileName: * cn.netdiscovery.monica.ui.widget.Title * @author: Tony Shen * @date: 2024/10/2 22:22 * @version: V1.0 <描述当前版本功能> */ @Composable fun title( modifier: Modifier = Modifier, text: String, color: Color = MaterialTheme.colors.primary, fontSize: TextUnit = titleTextSize, fontWeight: FontWeight? = null ) { Text( modifier = modifier, text = text, color = color, fontSize = fontSize, fontWeight = fontWeight ) } @Composable fun subTitle( modifier: Modifier = Modifier, text: String, color: Color = MaterialTheme.colors.primary, fontSize: TextUnit = subTitleTextSize, fontWeight: FontWeight? = null ) { Text( modifier = modifier, text = text, color = color, fontSize = fontSize, fontWeight = fontWeight ) } @Composable fun subTitleWithDivider( modifier: Modifier = Modifier, text: String, color: Color = MaterialTheme.colors.primary, fontSize: TextUnit = subTitleTextSize, fontWeight: FontWeight? = null ) { subTitle(modifier = modifier, text = text, color = color, fontSize = fontSize, fontWeight = fontWeight) divider() } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/Toasts.kt ================================================ package cn.netdiscovery.monica.ui.widget import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cn.netdiscovery.monica.utils.Action import kotlinx.coroutines.delay /** * * @FileName: * cn.netdiscovery.monica.ui.widget.Toasts * @author: Tony Shen * @date: 2024/5/28 15:13 * @version: V1.0 <描述当前版本功能> */ @Composable fun topToast( modifier: Modifier = Modifier, message: String = "", textColor: Color = Color.Gray, fontSize: TextUnit = 16.sp, height: Dp = 100.dp, width: Dp = 400.dp, onDismissCallback: Action = {}, ) { toast(modifier = modifier, message = message, textColor = textColor, fontSize = fontSize, height = height, width = width, alignment = Alignment.TopCenter, onDismissCallback = onDismissCallback) } @Composable fun centerToast( modifier: Modifier = Modifier, message: String = "", textColor: Color = Color.Gray, fontSize: TextUnit = 16.sp, height: Dp = 100.dp, width: Dp = 400.dp, onDismissCallback: Action = {}, ) { toast(modifier = modifier, message = message, textColor = textColor, fontSize = fontSize, height = height, width = width, alignment = Alignment.Center, onDismissCallback = onDismissCallback) } @Composable fun bottomToast( modifier: Modifier = Modifier, message: String = "", textColor: Color = Color.Gray, fontSize: TextUnit = 16.sp, height: Dp = 100.dp, width: Dp = 400.dp, onDismissCallback: Action = {}, ) { toast(modifier = modifier, message = message, textColor = textColor, fontSize = fontSize, height = height, width = width, alignment = Alignment.BottomCenter, onDismissCallback = onDismissCallback) } @Composable private fun toast( modifier: Modifier = Modifier, message: String = "An unexpected error occurred. Please try again later", textColor: Color = Color.Black, fontSize: TextUnit = 16.sp, height: Dp = 100.dp, width: Dp = 400.dp, alignment: Alignment, onDismissCallback: Action = {} ) { var hasTransitionStarted by remember { mutableStateOf(false) } var clipShape by remember { mutableStateOf(CircleShape) } var slideDownAnimation by remember { mutableStateOf(true) } var animationStarted by remember { mutableStateOf(false) } var showMessage by remember { mutableStateOf(false) } var dismissCallback by remember { mutableStateOf(false) } val boxWidth by animateDpAsState( targetValue = if (hasTransitionStarted) width else 30.dp, animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing), label = "Box width", ) val boxHeight by animateDpAsState( targetValue = if (hasTransitionStarted) height else 30.dp, animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing), label = "Box height", ) val slideY by animateDpAsState( targetValue = if (slideDownAnimation) (-100).dp else 0.dp, animationSpec = tween(durationMillis = 100), label = "Slide parameter in DP", ) LaunchedEffect(message) { // 重置状态 hasTransitionStarted = false clipShape = CircleShape slideDownAnimation = true animationStarted = false showMessage = false dismissCallback = false slideDownAnimation = false // Delay for 0.2 seconds before transitioning to rectangle delay(200) hasTransitionStarted = true clipShape = RoundedCornerShape(12.dp, 12.dp, 12.dp, 12.dp) showMessage = true // Delay for 2.5 seconds before reverting to circle delay(2500) hasTransitionStarted = false showMessage = false // Delay for 0.2 seconds before sliding up delay(200) clipShape = CircleShape slideDownAnimation = true animationStarted = true dismissCallback = true } Box( modifier = Modifier .fillMaxSize() .background(Color.Transparent) .padding(16.dp), ) { Box( modifier = modifier .size(boxWidth, boxHeight) .offset(y = slideY) .clip(clipShape) .background(MaterialTheme.colors.primary.copy(0.7f)) .align(alignment = alignment), contentAlignment = Alignment.Center, ) { if (showMessage) { Text( text = message, color = textColor, fontWeight = FontWeight.Bold, fontSize = fontSize, textAlign = TextAlign.Center, modifier = Modifier .padding(16.dp), ) } if (dismissCallback) onDismissCallback() } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/color/ColorSelection.kt ================================================ package cn.netdiscovery.monica.ui.widget.color import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.material.Slider import androidx.compose.material.SliderDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** * * @FileName: * cn.netdiscovery.monica.ui.showimage.ColorSelection * @author: Tony Shen * @date: 2024/5/19 10:43 * @version: V1.0 <描述当前版本功能> */ val gradientColors = listOf( Color.Red, Color.Magenta, Color.Blue, Color.Cyan, Color.Green, Color.Yellow, Color.Red ) @Composable fun ColorWheel(modifier: Modifier = Modifier) { Canvas(modifier = modifier) { val canvasWidth = size.width val canvasHeight = size.height require(canvasWidth == canvasHeight, lazyMessage = { print("Canvas dimensions should be equal to each other") } ) val cX = canvasWidth / 2 val cY = canvasHeight / 2 val canvasRadius = canvasWidth.coerceAtMost(canvasHeight) / 2f val center = Offset(cX, cY) val strokeWidth = canvasRadius * .3f // Stroke is drawn out of the radius, so it's required to subtract stroke width from radius val radius = canvasRadius - strokeWidth drawCircle( brush = Brush.sweepGradient(colors = gradientColors, center = center), radius = radius, center = center, style = Stroke( width = strokeWidth ) ) } } /** * Composable that shows a title as initial letter, title color and a Slider to pick color */ @Composable fun ColorSlider( modifier: Modifier, title: String, titleColor: Color, valueRange: ClosedFloatingPointRange = 0f..255f, rgb: Float, onColorChanged: (Float) -> Unit ) { val focusRequester = remember { FocusRequester() } Row(modifier, verticalAlignment = Alignment.CenterVertically) { Text(text = title.take(1), color = titleColor, fontWeight = FontWeight.Bold) Spacer(modifier = Modifier.width(8.dp)) Slider( modifier = Modifier.weight(1f).focusRequester(focusRequester), value = rgb, onValueChange = { onColorChanged(it) }, valueRange = valueRange, onValueChangeFinished = {}, colors = SliderDefaults.colors( thumbColor = titleColor, activeTrackColor = titleColor ) ) Spacer(modifier = Modifier.width(8.dp)) Text( text = rgb.toInt().toString(), color = Color.LightGray, fontSize = 12.sp, modifier = Modifier.width(30.dp) ) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/color/ColorSelectionDialog.kt ================================================ package cn.netdiscovery.monica.ui.widget.color import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import kotlin.math.roundToInt /** * * @FileName: * cn.netdiscovery.monica.ui.showimage.ColorSelectionDialog * @author: Tony Shen * @date: 2024/5/19 10:41 * @version: V1.0 <描述当前版本功能> */ val Blue400 = Color(0xff42A5F5) @Composable fun ColorSelectionDialog( initialColor: Color, onDismiss: () -> Unit, onNegativeClick: () -> Unit, onPositiveClick: (Color) -> Unit ) { var red by remember { mutableStateOf(initialColor.red * 255) } var green by remember { mutableStateOf(initialColor.green * 255) } var blue by remember { mutableStateOf(initialColor.blue * 255) } var alpha by remember { mutableStateOf(initialColor.alpha * 255) } val color = Color( red = red.roundToInt(), green = green.roundToInt(), blue = blue.roundToInt(), alpha = alpha.roundToInt() ) Dialog(onDismissRequest = onDismiss) { BoxWithConstraints( Modifier .shadow(1.dp, RoundedCornerShape(8.dp)) .background(Color.White) ) { val widthInDp = LocalDensity.current.run { maxWidth } Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = "Color", color = Blue400, fontSize = 18.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 12.dp) ) // Initial and Current Colors Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 50.dp, vertical = 20.dp) ) { Box( modifier = Modifier .weight(1f) .height(40.dp) .background( initialColor, shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp) ) ) Box( modifier = Modifier .weight(1f) .height(40.dp) .background( color, shape = RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp) ) ) } ColorWheel( modifier = Modifier .width(widthInDp * .8f) .aspectRatio(1f) ) Spacer(modifier = Modifier.height(16.dp)) // Sliders ColorSlider( modifier = Modifier .padding(start = 12.dp, end = 12.dp) .fillMaxWidth(), title = "Red", titleColor = Color.Red, rgb = red, onColorChanged = { red = it } ) Spacer(modifier = Modifier.height(4.dp)) ColorSlider( modifier = Modifier .padding(start = 12.dp, end = 12.dp) .fillMaxWidth(), title = "Green", titleColor = Color.Green, rgb = green, onColorChanged = { green = it } ) Spacer(modifier = Modifier.height(4.dp)) ColorSlider( modifier = Modifier .padding(start = 12.dp, end = 12.dp) .fillMaxWidth(), title = "Blue", titleColor = Color.Blue, rgb = blue, onColorChanged = { blue = it } ) Spacer(modifier = Modifier.height(4.dp)) ColorSlider( modifier = Modifier .padding(start = 12.dp, end = 12.dp) .fillMaxWidth(), title = "Alpha", titleColor = Color.Black, rgb = alpha, onColorChanged = { alpha = it } ) Spacer(modifier = Modifier.height(24.dp)) // Buttons Row( modifier = Modifier .fillMaxWidth() .height(60.dp) .background(Color(0xffF3E5F5)), verticalAlignment = Alignment.CenterVertically ) { TextButton( onClick = onNegativeClick, modifier = Modifier .weight(1f) .fillMaxHeight() ) { Text(text = "CANCEL") } TextButton( modifier = Modifier .weight(1f) .fillMaxHeight(), onClick = { onPositiveClick(color) }, ) { Text(text = "OK") } } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/image/ImageContentScaleUtil.kt ================================================ package cn.netdiscovery.monica.ui.widget.image import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize /** * * @FileName: * cn.netdiscovery.monica.ui.image.ImageContentScaleUtil * @author: Tony Shen * @date: 2024/5/14 15:47 * @version: V1.0 <描述当前版本功能> */ /** * Get Rectangle of [ImageBitmap] with [bitmapWidth] and [bitmapHeight] that is drawn inside * Canvas with [imageWidth] and [imageHeight]. [boxWidth] and [boxHeight] belong * to [BoxWithConstraints] that contains Canvas. * @param boxWidth width of the parent container * @param boxHeight height of the parent container * @param imageWidth width of the [Canvas] that draws [ImageBitmap] * @param imageHeight height of the [Canvas] that draws [ImageBitmap] * @param bitmapWidth intrinsic width of the [ImageBitmap] * @param bitmapHeight intrinsic height of the [ImageBitmap] * @return [IntRect] that covers [ImageBitmap] bounds. When image [ContentScale] is crop * this rectangle might return smaller rectangle than actual [ImageBitmap] and left or top * of the rectangle might be bigger than zero. */ internal fun getScaledBitmapRect( boxWidth: Int, boxHeight: Int, imageWidth: Float, imageHeight: Float, bitmapWidth: Int, bitmapHeight: Int ): IntRect { // Get scale of box to width of the image // We need a rect that contains Bitmap bounds to pass if any child requires it // For a image with 100x100 px with 300x400 px container and image with crop 400x400px // So we need to pass top left as 0,50 and size val scaledBitmapX = boxWidth / imageWidth val scaledBitmapY = boxHeight / imageHeight val topLeft = IntOffset( x = (bitmapWidth * (imageWidth - boxWidth) / imageWidth / 2) .coerceAtLeast(0f).toInt(), y = (bitmapHeight * (imageHeight - boxHeight) / imageHeight / 2) .coerceAtLeast(0f).toInt() ) val size = IntSize( width = (bitmapWidth * scaledBitmapX).toInt().coerceAtMost(bitmapWidth), height = (bitmapHeight * scaledBitmapY).toInt().coerceAtMost(bitmapHeight) ) return IntRect(offset = topLeft, size = size) } /** * Get [IntSize] of the parent or container that contains [Canvas] that draws [ImageBitmap] * @param bitmapWidth intrinsic width of the [ImageBitmap] * @param bitmapHeight intrinsic height of the [ImageBitmap] * @return size of parent Composable. When Modifier is assigned with fixed or finite size * they are used, but when any dimension is set to infinity intrinsic dimensions of * [ImageBitmap] are returned */ internal fun BoxWithConstraintsScope.getParentSize( bitmapWidth: Int, bitmapHeight: Int ): IntSize { // Check if Composable has fixed size dimensions val hasBoundedDimens = constraints.hasBoundedWidth && constraints.hasBoundedHeight // Check if Composable has infinite dimensions val hasFixedDimens = constraints.hasFixedWidth && constraints.hasFixedHeight // Box is the parent(BoxWithConstraints) that contains Canvas under the hood // Canvas aspect ratio or size might not match parent but it's upper bounds are // what are passed from parent. Canvas cannot be bigger or taller than BoxWithConstraints val boxWidth: Int = if (hasBoundedDimens || hasFixedDimens) { constraints.maxWidth } else { constraints.minWidth.coerceAtLeast(bitmapWidth) } val boxHeight: Int = if (hasBoundedDimens || hasFixedDimens) { constraints.maxHeight } else { constraints.minHeight.coerceAtLeast(bitmapHeight) } return IntSize(boxWidth, boxHeight) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/image/ImageScope.kt ================================================ package cn.netdiscovery.monica.ui.widget.image import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toAwtImage import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntRect import cn.netdiscovery.monica.imageprocess.utils.extension.subImage /** * * @FileName: * cn.netdiscovery.monica.ui.image.ImageScope * @author: Tony Shen * @date: 2024/5/14 15:25 * @version: V1.0 <描述当前版本功能> */ @Stable interface ImageScope { /** * The constraints given by the parent layout in pixels. * * Use [minWidth], [maxWidth], [minHeight] or [maxHeight] if you need value in [Dp]. */ val constraints: Constraints /** * The minimum width in [Dp]. * * @see constraints for the values in pixels. */ val minWidth: Dp /** * The maximum width in [Dp]. * * @see constraints for the values in pixels. */ val maxWidth: Dp /** * The minimum height in [Dp]. * * @see constraints for the values in pixels. */ val minHeight: Dp /** * The maximum height in [Dp]. * * @see constraints for the values in pixels. */ val maxHeight: Dp /** * Width of area inside BoxWithConstraints that is scaled based on [ContentScale] * This is width of the [Canvas] draw [ImageBitmap] */ val imageWidth: Dp /** * Height of area inside BoxWithConstraints that is scaled based on [ContentScale] * This is height of the [Canvas] draw [ImageBitmap] */ val imageHeight: Dp /** * [IntRect] that covers boundaries of [ImageBitmap] */ val rect: IntRect } internal data class ImageScopeImpl( private val density: Density, override val constraints: Constraints, override val imageWidth: Dp, override val imageHeight: Dp, override val rect: IntRect, ) : ImageScope { override val minWidth: Dp get() = with(density) { constraints.minWidth.toDp() } override val maxWidth: Dp get() = with(density) { if (constraints.hasBoundedWidth) constraints.maxWidth.toDp() else Dp.Infinity } override val minHeight: Dp get() = with(density) { constraints.minHeight.toDp() } override val maxHeight: Dp get() = with(density) { if (constraints.hasBoundedHeight) constraints.maxHeight.toDp() else Dp.Infinity } } @Composable internal fun getScaledImageBitmap( imageWidth: Dp, imageHeight: Dp, rect: IntRect, bitmap: ImageBitmap, contentScale: ContentScale ): ImageBitmap { val scaledBitmap = remember(bitmap, rect, imageWidth, imageHeight, contentScale) { bitmap.toAwtImage().subImage(rect.left,rect.top,rect.width,rect.height).toComposeImageBitmap() } return scaledBitmap } @Composable internal fun ImageScope.getScaledImageBitmap( bitmap: ImageBitmap, contentScale: ContentScale ): ImageBitmap { val scaledBitmap = remember(bitmap, rect, imageWidth, imageHeight, contentScale) { bitmap.toAwtImage().subImage(rect.left,rect.top,rect.width,rect.height).toComposeImageBitmap() } return scaledBitmap } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/image/ImageSizeCalculator.kt ================================================ package cn.netdiscovery.monica.ui.widget.image import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import cn.netdiscovery.monica.state.ApplicationState import androidx.compose.ui.graphics.toComposeImageBitmap import cn.netdiscovery.monica.ui.controlpanel.filter.viewmodel.FilterViewModel import cn.netdiscovery.monica.utils.logger import org.slf4j.Logger import org.slf4j.LoggerFactory /** * 统一的图片尺寸计算工具 * 确保在不同页面中图片显示大小一致 * * @author Tony Shen * @date 2025/9/4 * @version V1.0 */ object ImageSizeCalculator { private val logger: Logger = logger() // 默认最大尺寸配置 - 增加尺寸以支持更大的图片 private const val DEFAULT_MAX_WIDTH_DP = 1600f // 从1200增加到1600 private const val DEFAULT_MAX_HEIGHT_DP = 1200f // 从800增加到1200 private const val MIN_DENSITY = 0.1f // 防止除零错误 /** * 计算统一的图片显示尺寸 * @param state 应用状态 * @param maxWidthDp 最大宽度(dp) * @param maxHeightDp 最大高度(dp) * @return 显示尺寸对 */ @androidx.compose.runtime.Composable fun calculateImageSize( state: ApplicationState, maxWidthDp: Float = DEFAULT_MAX_WIDTH_DP, maxHeightDp: Float = DEFAULT_MAX_HEIGHT_DP ): Pair { val density = LocalDensity.current // 安全检查密度值 val safeDensity = if (density.density < MIN_DENSITY) { logger.warn("检测到异常密度值: ${density.density}, 使用默认值 1.0") 1.0f } else { density.density } val image = state.currentImage?.toComposeImageBitmap() return if (image != null && image.width > 0 && image.height > 0) { val bitmapWidth = image.width val bitmapHeight = image.height // 原始图片尺寸(dp) val originalWidthDp = bitmapWidth / safeDensity val originalHeightDp = bitmapHeight / safeDensity // 计算缩放比例,保持宽高比 val scale = minOf( maxWidthDp / originalWidthDp, maxHeightDp / originalHeightDp ).coerceAtMost(1f) // 不放大图片,只缩小 val displayWidth = (originalWidthDp * scale).dp val displayHeight = (originalHeightDp * scale).dp logger.debug("图片尺寸计算: 原始(${bitmapWidth}x${bitmapHeight}) -> 显示(${displayWidth.value}dp x ${displayHeight.value}dp)") displayWidth to displayHeight } else { logger.warn("图片为空或尺寸无效,返回默认尺寸") 0.dp to 0.dp } } /** * 获取图片的像素尺寸 * @param state 应用状态 * @return 图片的宽度和高度(像素) */ fun getImagePixelSize(state: ApplicationState): Pair? { val image = state.currentImage?.toComposeImageBitmap() return if (image != null && image.width > 0 && image.height > 0) { image.width to image.height } else { logger.warn("无法获取有效的图片像素尺寸") null } } /** * 获取图片的显示尺寸(像素)- 非Composable版本 * @param state 应用状态 * @param density 密度值 * @return 图片的显示宽度和高度(像素) */ fun getImageDisplayPixelSize(state: ApplicationState, density: Float): Pair? { val image = state.currentImage?.toComposeImageBitmap() return if (image != null && image.width > 0 && image.height > 0) { val bitmapWidth = image.width val bitmapHeight = image.height // 原始图片尺寸(dp) val originalWidthDp = bitmapWidth / density val originalHeightDp = bitmapHeight / density // 计算缩放比例,保持宽高比 val scale = minOf( DEFAULT_MAX_WIDTH_DP / originalWidthDp, DEFAULT_MAX_HEIGHT_DP / originalHeightDp ).coerceAtMost(1f) // 不放大图片,只缩小 val displayWidthPx = (originalWidthDp * scale * density).toInt() val displayHeightPx = (originalHeightDp * scale * density).toInt() displayWidthPx to displayHeightPx } else { null } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/image/ImageWithConstraints.kt ================================================ package cn.netdiscovery.monica.ui.widget.image import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.DefaultAlpha import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize /** * * @FileName: * cn.netdiscovery.monica.ui.image.ImageWithConstraints * @author: Tony Shen * @date: 2024/5/14 15:45 * @version: V1.0 <描述当前版本功能> */ @Composable fun ImageWithConstraints( modifier: Modifier = Modifier, imageBitmap: ImageBitmap, alignment: Alignment = Alignment.Center, contentScale: ContentScale = ContentScale.Fit, contentDescription: String? = null, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, drawImage: Boolean = true, content: @Composable ImageScope.() -> Unit = {} ) { val semantics = if (contentDescription != null) { Modifier.semantics { this.contentDescription = contentDescription this.role = Role.Image } } else { Modifier } BoxWithConstraints( modifier = modifier .then(semantics), contentAlignment = alignment, ) { val bitmapWidth = imageBitmap.width val bitmapHeight = imageBitmap.height val (boxWidth: Int, boxHeight: Int) = getParentSize(bitmapWidth, bitmapHeight) // Src is Bitmap, Dst is the container(Image) that Bitmap will be displayed val srcSize = Size(bitmapWidth.toFloat(), bitmapHeight.toFloat()) val dstSize = Size(boxWidth.toFloat(), boxHeight.toFloat()) val scaleFactor = contentScale.computeScaleFactor(srcSize, dstSize) // Image is the container for bitmap that is located inside Box // image bounds can be smaller or bigger than its parent based on how it's scaled val imageWidth = bitmapWidth * scaleFactor.scaleX val imageHeight = bitmapHeight * scaleFactor.scaleY val bitmapRect = getScaledBitmapRect( boxWidth = boxWidth, boxHeight = boxHeight, imageWidth = imageWidth, imageHeight = imageHeight, bitmapWidth = bitmapWidth, bitmapHeight = bitmapHeight ) ImageLayout( constraints = constraints, imageBitmap = imageBitmap, bitmapRect = bitmapRect, imageWidth = imageWidth, imageHeight = imageHeight, boxWidth = boxWidth, boxHeight = boxHeight, alpha = alpha, colorFilter = colorFilter, filterQuality = filterQuality, drawImage = drawImage, content = content ) } } @Composable private fun ImageLayout( constraints: Constraints, imageBitmap: ImageBitmap, bitmapRect: IntRect, imageWidth: Float, imageHeight: Float, boxWidth: Int, boxHeight: Int, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, drawImage: Boolean = true, content: @Composable ImageScope.() -> Unit ) { val density = LocalDensity.current // Dimensions of canvas that will draw this Bitmap val canvasWidthInDp: Dp val canvasHeightInDp: Dp with(density) { canvasWidthInDp = imageWidth.coerceAtMost(boxWidth.toFloat()).toDp() canvasHeightInDp = imageHeight.coerceAtMost(boxHeight.toFloat()).toDp() } // Send rectangle of Bitmap drawn to Canvas as bitmapRect, content scale modes like // crop might crop image from center so Rect can be such as IntRect(250,250,500,500) // canvasWidthInDp, and canvasHeightInDp are Canvas dimensions coerced to Box size // that covers Canvas val imageScopeImpl = ImageScopeImpl( density = density, constraints = constraints, imageWidth = canvasWidthInDp, imageHeight = canvasHeightInDp, rect = bitmapRect ) // width and height params for translating draw position if scaled Image dimensions are // bigger than Canvas dimensions if (drawImage) { ImageImpl( modifier = Modifier.size(canvasWidthInDp, canvasHeightInDp), imageBitmap = imageBitmap, alpha = alpha, width = imageWidth.toInt(), height = imageHeight.toInt(), colorFilter = colorFilter, filterQuality = filterQuality ) } imageScopeImpl.content() } @Composable private fun ImageImpl( modifier: Modifier, imageBitmap: ImageBitmap, width: Int, height: Int, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { val bitmapWidth = imageBitmap.width val bitmapHeight = imageBitmap.height Canvas(modifier = modifier.clipToBounds()) { val canvasWidth = size.width.toInt() val canvasHeight = size.height.toInt() // Translate to left or down when Image size is bigger than this canvas. // ImageSize is bigger when scale modes like Crop is used which enlarges image // For instance 1000x1000 image can be 1000x2000 for a Canvas with 1000x1000 // so top is translated -500 to draw center of ImageBitmap translate( top = (-height + canvasHeight) / 2f, left = (-width + canvasWidth) / 2f ) { drawImage( imageBitmap, srcSize = IntSize(bitmapWidth, bitmapHeight), dstSize = IntSize(width, height), alpha = alpha, colorFilter = colorFilter, filterQuality = filterQuality ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/image/ImageWithThumbnail.kt ================================================ package cn.netdiscovery.monica.ui.widget.image import androidx.compose.foundation.Canvas import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.* import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.* import cn.netdiscovery.monica.ui.widget.image.gesture.pointerMotionEvents import kotlin.math.roundToInt /** * * @FileName: * cn.netdiscovery.monica.ui.widget.image.ImageWithThumbnail * @author: Tony Shen * @date: 2024/6/13 21:52 * @version: V1.0 <描述当前版本功能> */ @Composable fun ImageWithThumbnail( modifier: Modifier = Modifier, imageBitmap: ImageBitmap, contentScale: ContentScale = ContentScale.Fit, alignment: Alignment = Alignment.Center, contentDescription: String?, thumbnailState: ThumbnailState = rememberThumbnailState(), alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, drawOriginalImage: Boolean = true, onDown: ((Offset) -> Unit)? = null, onMove: ((Offset) -> Unit)? = null, onUp: (() -> Unit)? = null, onThumbnailCenterChange: ((Offset) -> Unit)? = null, content: @Composable ImageScope.() -> Unit = {} ) { ImageWithConstraints( modifier = modifier, contentScale = contentScale, alignment = alignment, contentDescription = contentDescription, alpha = alpha, colorFilter = colorFilter, filterQuality = filterQuality, imageBitmap = imageBitmap, drawImage = drawOriginalImage ) { val imageScope = this val density = LocalDensity.current val scaledImageBitmap = getScaledImageBitmap(imageBitmap, contentScale) val size = rememberUpdatedState( newValue = Size( width = imageWidth.value * density.density, height = imageHeight.value * density.density ) ) var offset by remember(key1 = contentScale, key2 = scaledImageBitmap) { mutableStateOf( Offset.Unspecified ) } fun updateOffset(pointerInputChange: PointerInputChange): Offset { val offsetX = pointerInputChange.position.x .coerceIn(0f, size.value.width) val offsetY = pointerInputChange.position.y .coerceIn(0f, size.value.height) pointerInputChange.consume() return Offset(offsetX, offsetY) } val thumbnailModifier = Modifier .pointerMotionEvents( key1 = contentScale, key2 = scaledImageBitmap, onDown = { pointerInputChange: PointerInputChange -> offset = updateOffset(pointerInputChange) onDown?.invoke(offset) }, onMove = { pointerInputChange: PointerInputChange -> offset = updateOffset(pointerInputChange) onMove?.invoke(offset) }, onUp = { onUp?.invoke() } ) ThumbnailLayout( modifier = thumbnailModifier.size(this.imageWidth, this.imageHeight), imageBitmap = scaledImageBitmap, thumbnailState = thumbnailState, offset = offset, onThumbnailCenterChange = onThumbnailCenterChange ) Box( modifier = Modifier .size(this.imageWidth, this.imageHeight), ) { imageScope.content() } } } /** * [ThumbnailLayout] displays thumbnail of bitmap it draws in corner specified * by [ThumbnailState.position]. When touch position is close to thumbnail * position if [ThumbnailState.dynamicPosition] * is set to true moves thumbnail to corner specified by [ThumbnailState.moveTo] * * @param imageBitmap The [ImageBitmap] to draw * into the destination. The default is [FilterQuality.Low] which scales using a bilinear * sampling algorithm * * @param onThumbnailCenterChange callback to get center of thumbnail */ @Composable private fun ThumbnailLayout( modifier: Modifier, imageBitmap: ImageBitmap, thumbnailState: ThumbnailState, alpha: Float = 1.0f, colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, offset: Offset, onThumbnailCenterChange: ((Offset) -> Unit)? = null ) { ThumbnailLayoutImpl( modifier = modifier, imageBitmap = imageBitmap, thumbnailState = thumbnailState, offset = offset, alpha = alpha, colorFilter = colorFilter, filterQuality = filterQuality, onThumbnailCenterChange = onThumbnailCenterChange ) } @Composable private fun ThumbnailLayoutImpl( modifier: Modifier, imageBitmap: ImageBitmap, thumbnailState: ThumbnailState, offset: Offset, alpha: Float = 1.0f, colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, onThumbnailCenterChange: ((Offset) -> Unit)? = null ) { val thumbnailSize = thumbnailState.size val thumbnailPosition = thumbnailState.position val dynamicPosition = thumbnailState.dynamicPosition val moveTo = thumbnailState.moveTo val thumbnailZoom = thumbnailState.thumbnailZoom BoxWithConstraints(modifier) { val canvasWidth = constraints.maxWidth.toFloat() val canvasHeight = constraints.maxHeight.toFloat() val thumbnailWidthInPx: Float val thumbnailHeightInPx: Float with(LocalDensity.current) { thumbnailWidthInPx = thumbnailSize.width.toPx() thumbnailHeightInPx = thumbnailSize.height.toPx() } // Get thumbnail size as parameter but limit max size to minimum of canvasWidth and Height val imageThumbnailWidth: Int = thumbnailWidthInPx.coerceAtMost(canvasWidth).roundToInt() val imageThumbnailHeight: Int = thumbnailHeightInPx.coerceAtMost(canvasHeight).roundToInt() val thumbnailOffset = getThumbnailPositionOffset( offset = offset, canvasWidth = canvasWidth, canvasHeight = canvasHeight, imageThumbnailWidth = imageThumbnailWidth, imageThumbnailHeight = imageThumbnailHeight, thumbnailPosition = thumbnailPosition, dynamicPosition = dynamicPosition, moveTo = moveTo ) // Center of thumbnail val centerX: Float = thumbnailOffset.x + imageThumbnailWidth / 2f val centerY: Float = thumbnailOffset.y + imageThumbnailHeight / 2f onThumbnailCenterChange?.invoke(Offset(centerX, centerY)) Canvas(modifier = Modifier .offset { thumbnailOffset } .then( thumbnailState.shadow?.let { shadow: MaterialShadow -> Modifier.shadow( elevation = shadow.elevation, shape = thumbnailState.shape, ambientColor = shadow.ambientShadowColor, spotColor = shadow.spotColor ) } ?: Modifier ) .then( thumbnailState.border?.let { border: Border -> Modifier.border( width = border.strokeWidth, shape = thumbnailState.shape, brush = border.color ) } ?: Modifier ) .size(thumbnailSize) ) { val zoom = thumbnailZoom.coerceAtLeast(100) val zoomScale = zoom / 100f val srcOffset = if (offset.isSpecified && offset.isFinite) { getSrcOffset( offset = offset, imageBitmap = imageBitmap, zoomScale = zoomScale, size = Size(canvasWidth, canvasHeight), imageThumbnailSize = imageThumbnailWidth ) } else { IntOffset.Zero } drawImage( image = imageBitmap, srcOffset = srcOffset, srcSize = IntSize( width = (imageThumbnailWidth / zoomScale).toInt(), height = (imageThumbnailWidth / zoomScale).toInt() ), dstSize = IntSize(imageThumbnailWidth, imageThumbnailWidth), alpha = alpha, colorFilter = colorFilter, filterQuality = filterQuality, ) } } } private fun getThumbnailPositionOffset( offset: Offset, canvasWidth: Float, canvasHeight: Float, thumbnailPosition: ThumbnailPosition = ThumbnailPosition.TopLeft, dynamicPosition: Boolean = true, moveTo: ThumbnailPosition = ThumbnailPosition.TopRight, imageThumbnailWidth: Int, imageThumbnailHeight: Int ): IntOffset { val thumbnailOffset = calculateThumbnailOffset( thumbnailPosition, canvasWidth, canvasHeight, imageThumbnailWidth, imageThumbnailHeight ) if (offset.isUnspecified || !offset.isFinite) return thumbnailOffset if (!dynamicPosition || thumbnailPosition == moveTo) return thumbnailOffset val offsetX = offset.x .coerceIn(0f, canvasWidth) val offsetY = offset.y .coerceIn(0f, canvasHeight) // Calculate distance from touch position to center of thumbnail val x = offsetX - (thumbnailOffset.x + imageThumbnailWidth / 2) val y = offsetY - (thumbnailOffset.y + imageThumbnailHeight / 2) val distanceToThumbnailCenter = (x * x + y * y) // pointer position is in bounds of thumbnail, calculate alternative position to move to return if (distanceToThumbnailCenter < imageThumbnailWidth * imageThumbnailHeight) { calculateThumbnailOffset( moveTo, canvasWidth, canvasHeight, imageThumbnailWidth, imageThumbnailHeight ) } else { thumbnailOffset } } /** * Calculate thumbnail position based on which corner it's in */ private fun calculateThumbnailOffset( thumbnailPosition: ThumbnailPosition, canvasWidth: Float, canvasHeight: Float, imageThumbnailWidth: Int, imageThumbnailHeight: Int ): IntOffset { return when (thumbnailPosition) { ThumbnailPosition.TopLeft -> { IntOffset(x = 0, y = 0) } ThumbnailPosition.TopRight -> { IntOffset(x = (canvasWidth - imageThumbnailWidth).toInt(), y = 0) } ThumbnailPosition.BottomLeft -> { IntOffset(x = 0, y = (canvasHeight - imageThumbnailHeight).toInt()) } ThumbnailPosition.BottomRight -> { IntOffset( x = (canvasWidth - imageThumbnailWidth).toInt(), y = (canvasHeight - imageThumbnailHeight).toInt() ) } } } /** * Get offset for Src. Src is the bitmap that will be drawn to canvas. Based on it's * size and offset any section or whole bitmap can be drawn. * Setting positive offset on x axis moves visible section of bitmap to the left. * @param offset pointer touch position * @param imageBitmap is image that will be drawn * @param zoomScale scale of zoom between [1f-5f] */ private fun getSrcOffset( offset: Offset, imageBitmap: ImageBitmap, zoomScale: Float, size: Size, imageThumbnailSize: Int ): IntOffset { val canvasWidth = size.width val canvasHeight = size.height val bitmapWidth = imageBitmap.width val bitmapHeight = imageBitmap.height val offsetX = offset.x .coerceIn(0f, canvasWidth) val offsetY = offset.y .coerceIn(0f, canvasHeight) // Setting offset for src moves the position in Bitmap // Bitmap is SRC while where we draw is DST. // Setting offset of dst moves where we draw in Canvas // Setting src moves to which part of the bitmap we draw // Coercing at right bound (bitmap.width - imageThumbnailSize) lets to limit offset // to thumbnailSize when user moves pointer to right. // If image has 100px width and thumbnail 10 when user moves to 95 we see a width with 5px // coercing lets you keep 10px all the time val srcOffsetX = (offsetX * bitmapWidth / canvasWidth - imageThumbnailSize / zoomScale / 2) .coerceIn(0f, bitmapWidth - imageThumbnailSize / zoomScale) val srcOffsetY = (offsetY * bitmapHeight / canvasHeight - imageThumbnailSize / zoomScale / 2) .coerceIn(0f, bitmapHeight - imageThumbnailSize / zoomScale) return IntOffset(srcOffsetX.toInt(), srcOffsetY.toInt()) } enum class ThumbnailPosition { TopLeft, TopRight, BottomLeft, BottomRight } /** * Creates and stores UI properties for [ImageWithThumbnail]. * @param size size of the thumbnail * @param position position of the thumbnail. It's top left corner by default * @param dynamicPosition flag that changes mobility of thumbnail when user touch is * in proximity of the thumbnail * @param moveTo corner to move thumbnail if user touch is in proximity of the thumbnail. By default * it's top right corner. * @param thumbnailZoom zoom amount of thumbnail. It's in range of [100-500]. 100 corresponds * @param shape of the thumbnail * @param shadow if not null draws shadow behind thumbnail with given [shape] * @param border if not null draws border around thumbnail with given [shape] */ @Composable fun rememberThumbnailState( size: DpSize = DpSize(80.dp, 80.dp), position: ThumbnailPosition = ThumbnailPosition.TopLeft, dynamicPosition: Boolean = true, moveTo: ThumbnailPosition = ThumbnailPosition.TopRight, thumbnailZoom: Int = 200, shape: Shape = RoundedCornerShape(8.dp), shadow: MaterialShadow = MaterialShadow( elevation = 2.dp, ambientShadowColor = DefaultShadowColor, spotColor = DefaultShadowColor ), border: Border? = null, ): ThumbnailState { return remember { ThumbnailState( size = size, position = position, dynamicPosition = dynamicPosition, moveTo = moveTo, thumbnailZoom = thumbnailZoom, shape = shape, shadow = shadow, border = border ) } } @Immutable data class ThumbnailState internal constructor( @Stable val size: DpSize = DpSize(80.dp, 80.dp), @Stable val position: ThumbnailPosition = ThumbnailPosition.TopLeft, @Stable val dynamicPosition: Boolean = true, @Stable val moveTo: ThumbnailPosition = ThumbnailPosition.TopRight, @Stable val thumbnailZoom: Int = 200, val shape: Shape = RoundedCornerShape(8.dp), @Stable val shadow: MaterialShadow? = MaterialShadow( elevation = 2.dp, ambientShadowColor = DefaultShadowColor, spotColor = DefaultShadowColor ), @Stable val border: Border? = null ) @Immutable data class MaterialShadow( @Stable val elevation: Dp = 2.dp, @Stable val ambientShadowColor: Color = DefaultShadowColor, @Stable val spotColor: Color = DefaultShadowColor, ) @Composable fun Border( color: Color, strokeWidth: Dp, ): Border { return Border(strokeWidth = strokeWidth, color = SolidColor(color)) } @Composable fun Border( brush: Brush, strokeWidth: Dp ): Border { return Border(strokeWidth = strokeWidth, color = brush) } @Immutable data class Border internal constructor( @Stable val strokeWidth: Dp, @Stable val color: Brush ) val DefaultShadowColor = Color.Black ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/image/gesture/AwaitDragMotionModifier.kt ================================================ package cn.netdiscovery.monica.ui.widget.image.gesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation import androidx.compose.foundation.gestures.drag import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.consumePositionChange import androidx.compose.ui.input.pointer.pointerInput /** * * @FileName: * cn.netdiscovery.monica.ui.widget.image.gesture.AwaitDragMotionModifier * @author: Tony Shen * @date: 2024/5/20 19:18 * @version: V1.0 <描述当前版本功能> */ suspend fun AwaitPointerEventScope.awaitDragMotionEvent( onDragStart: (PointerInputChange) -> Unit = {}, onDrag: (PointerInputChange) -> Unit = {}, onDragEnd: (PointerInputChange) -> Unit = {} ) { // Wait for at least one pointer to press down, and set first contact position val down: PointerInputChange = awaitFirstDown() onDragStart(down) var pointer = down // 🔥 Waits for drag threshold to be passed by pointer // or it returns null if up event is triggered val change: PointerInputChange? = awaitTouchSlopOrCancellation(down.id) { change: PointerInputChange, over: Offset -> // 🔥🔥 If consumePositionChange() is not consumed drag does not // function properly. // Consuming position change causes change.positionChanged() to return false. change.consumePositionChange() } if (change != null) { // 🔥 Calls awaitDragOrCancellation(pointer) in a while loop drag(change.id) { pointerInputChange: PointerInputChange -> pointer = pointerInputChange onDrag(pointer) } // All of the pointers are up onDragEnd(pointer) } else { // Drag threshold is not passed and last pointer is up onDragEnd(pointer) } } fun Modifier.dragMotionEvent( onDragStart: (PointerInputChange) -> Unit = {}, onDrag: (PointerInputChange) -> Unit = {}, onDragEnd: (PointerInputChange) -> Unit = {} ) = this.then( Modifier.pointerInput(Unit) { forEachGesture { awaitPointerEventScope { awaitDragMotionEvent(onDragStart, onDrag, onDragEnd) } } } ) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/image/gesture/AwaitPointerMontionEvent.kt ================================================ package cn.netdiscovery.monica.ui.widget.image.gesture import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.ui.input.pointer.* import androidx.compose.ui.input.pointer.PointerEventPass.* import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** * * @FileName: * cn.netdiscovery.monica.ui.widget.image.gesture.AwaitPointerMontionEvent * @author: Tony Shen * @date: 2024/5/26 18:59 * @version: V1.0 <描述当前版本功能> */ suspend fun PointerInputScope.detectMotionEvents( onDown: (PointerInputChange) -> Unit = {}, onMove: (PointerInputChange) -> Unit = {}, onUp: (PointerInputChange) -> Unit = {}, delayAfterDownInMillis: Long = 0L, requireUnconsumed: Boolean = true, pass: PointerEventPass = PointerEventPass.Main ) { coroutineScope { forEachGesture { awaitPointerEventScope { // Wait for at least one pointer to press down, and set first contact position val down: PointerInputChange = awaitFirstDown(requireUnconsumed) onDown(down) var pointer = down // Main pointer is the one that is down initially var pointerId = down.id // If a move event is followed fast enough down is skipped, especially by Canvas // to prevent it we add delay after first touch var waitedAfterDown = false launch { delay(delayAfterDownInMillis) waitedAfterDown = true } while (true) { val event: PointerEvent = awaitPointerEvent(pass) val anyPressed = event.changes.any { it.pressed } // There are at least one pointer pressed if (anyPressed) { // Get pointer that is down, if first pointer is up // get another and use it if other pointers are also down // event.changes.first() doesn't return same order val pointerInputChange = event.changes.firstOrNull { it.id == pointerId } ?: event.changes.first() // Next time will check same pointer with this id pointerId = pointerInputChange.id pointer = pointerInputChange if (waitedAfterDown) { onMove(pointer) } } else { // All of the pointers are up onUp(pointer) break } } } } } } /** * Reads [awaitFirstDown], and [AwaitPointerEventScope.awaitPointerEvent] to * get [PointerInputChange] and motion event states * [onDown], [onMove], and [onUp]. Unlike overload of this function [onMove] returns * list of [PointerInputChange] to get data about all pointers that are on the screen. * * To prevent other pointer functions that call [awaitFirstDown] * or [AwaitPointerEventScope.awaitPointerEvent] * (scroll, swipe, detect functions) * receiving changes call [PointerInputChange.consume] in [onMove] or call * [PointerInputChange.consume] in [onDown] to prevent events * that check first pointer interaction. * * @param onDown is invoked when first pointer is down initially. * @param onMove one or multiple pointers are being moved on screen. * @param onUp last pointer is up * @param delayAfterDownInMillis is optional delay after [onDown] This delay might be * required Composables like **Canvas** to process [onDown] before [onMove] * @param requireUnconsumed is `true` and the first * down is consumed in the [PointerEventPass.Main] pass, that gesture is ignored. * @param pass The enumeration of passes where [PointerInputChange] * traverses up and down the UI tree. * * PointerInputChanges traverse throw the hierarchy in the following passes: * * 1. [Initial]: Down the tree from ancestor to descendant. * 2. [Main]: Up the tree from descendant to ancestor. * 3. [Final]: Down the tree from ancestor to descendant. * * These passes serve the following purposes: * * 1. Initial: Allows ancestors to consume aspects of [PointerInputChange] before descendants. * This is where, for example, a scroller may block buttons from getting tapped by other fingers * once scrolling has started. * 2. Main: The primary pass where gesture filters should react to and consume aspects of * [PointerInputChange]s. This is the primary path where descendants will interact with * [PointerInputChange]s before parents. This allows for buttons to respond to a tap before a * container of the bottom to respond to a tap. * 3. Final: This pass is where children can learn what aspects of [PointerInputChange]s were * consumed by parents during the [Main] pass. For example, this is how a button determines that * it should no longer respond to fingers lifting off of it because a parent scroller has * consumed movement in a [PointerInputChange]. * */ suspend fun PointerInputScope.detectMotionEventsAsList( onDown: (PointerInputChange) -> Unit = {}, onMove: (List) -> Unit = {}, onUp: (PointerInputChange) -> Unit = {}, delayAfterDownInMillis: Long = 0L, requireUnconsumed: Boolean = true, pass: PointerEventPass = PointerEventPass.Main ) { coroutineScope { awaitEachGesture { // Wait for at least one pointer to press down, and set first contact position val down: PointerInputChange = awaitFirstDown( requireUnconsumed = requireUnconsumed, pass = pass ) onDown(down) var pointer = down // Main pointer is the one that is down initially var pointerId = down.id // If a move event is followed fast enough down is skipped, especially by Canvas // to prevent it we add delay after first touch var waitedAfterDown = false launch { delay(delayAfterDownInMillis) waitedAfterDown = true } while (true) { val event: PointerEvent = awaitPointerEvent(pass) val anyPressed = event.changes.any { it.pressed } // There are at least one pointer pressed if (anyPressed) { // Get pointer that is down, if first pointer is up // get another and use it if other pointers are also down // event.changes.first() doesn't return same order val pointerInputChange = event.changes.firstOrNull { it.id == pointerId } ?: event.changes.first() // Next time will check same pointer with this id pointerId = pointerInputChange.id pointer = pointerInputChange if (waitedAfterDown) { onMove(event.changes) } } else { // All of the pointers are up onUp(pointer) break } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/image/gesture/MotionEvent.kt ================================================ package cn.netdiscovery.monica.ui.widget.image.gesture /** * * @FileName: * cn.netdiscovery.monica.ui.image.gesture.MotionEvent * @author: Tony Shen * @date: 2024/5/14 16:00 * @version: V1.0 <描述当前版本功能> */ enum class MotionEvent { Idle, Down, Move, Up } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/image/gesture/PointerMotionModify.kt ================================================ package cn.netdiscovery.monica.ui.widget.image.gesture import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput /** * * @FileName: * cn.netdiscovery.monica.ui.widget.image.gesture.PointerMotionModify * @author: Tony Shen * @date: 2024/6/13 22:00 * @version: V1.0 <描述当前版本功能> */ fun Modifier.pointerMotionEvents( onDown: (PointerInputChange) -> Unit = {}, onMove: (PointerInputChange) -> Unit = {}, onUp: (PointerInputChange) -> Unit = {}, delayAfterDownInMillis: Long = 0L, requireUnconsumed: Boolean = true, pass: PointerEventPass = PointerEventPass.Main, key1: Any?, key2: Any? ) = this.then( Modifier.pointerInput(key1, key2) { detectMotionEvents( onDown, onMove, onUp, delayAfterDownInMillis, requireUnconsumed, pass ) } ) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/image/gesture/TransformGestures.kt ================================================ package cn.netdiscovery.monica.ui.widget.image.gesture import androidx.compose.foundation.gestures.* import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.positionChanged import kotlin.math.PI import kotlin.math.abs /** * * @FileName: * cn.netdiscovery.monica.ui.widget.image.gesture.TransformGestures * @author: Tony Shen * @date: 2024/5/26 15:36 * @version: V1.0 <描述当前版本功能> */ suspend fun PointerInputScope.detectTransformGestures( panZoomLock: Boolean = false, consume: Boolean = true, pass: PointerEventPass = PointerEventPass.Main, onGestureStart: (PointerInputChange) -> Unit = {}, onGesture: ( centroid: Offset, pan: Offset, zoom: Float, rotation: Float, mainPointer: PointerInputChange, changes: List ) -> Unit, onGestureEnd: (PointerInputChange) -> Unit = {} ) { awaitEachGesture { var rotation = 0f var zoom = 1f var pan = Offset.Zero var pastTouchSlop = false val touchSlop = viewConfiguration.touchSlop var lockedToPanZoom = false // Wait for at least one pointer to press down, and set first contact position val down: PointerInputChange = awaitFirstDown( requireUnconsumed = false, pass = pass ) onGestureStart(down) var pointer = down // Main pointer is the one that is down initially var pointerId = down.id do { val event = awaitPointerEvent(pass = pass) // If any position change is consumed from another PointerInputChange // or pointer count requirement is not fulfilled val canceled = event.changes.any { it.isConsumed } if (!canceled) { // Get pointer that is down, if first pointer is up // get another and use it if other pointers are also down // event.changes.first() doesn't return same order val pointerInputChange = event.changes.firstOrNull { it.id == pointerId } ?: event.changes.first() // Next time will check same pointer with this id pointerId = pointerInputChange.id pointer = pointerInputChange val zoomChange = event.calculateZoom() val rotationChange = event.calculateRotation() val panChange = event.calculatePan() if (!pastTouchSlop) { zoom *= zoomChange rotation += rotationChange pan += panChange val centroidSize = event.calculateCentroidSize(useCurrent = false) val zoomMotion = abs(1 - zoom) * centroidSize val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f) val panMotion = pan.getDistance() if (zoomMotion > touchSlop || rotationMotion > touchSlop || panMotion > touchSlop ) { pastTouchSlop = true lockedToPanZoom = panZoomLock && rotationMotion < touchSlop } } if (pastTouchSlop) { val centroid = event.calculateCentroid(useCurrent = false) val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange if (effectiveRotation != 0f || zoomChange != 1f || panChange != Offset.Zero ) { onGesture( centroid, panChange, zoomChange, effectiveRotation, pointer, event.changes ) } if (consume) { event.changes.forEach { if (it.positionChanged()) { it.consume() } } } } } } while (!canceled && event.changes.any { it.pressed }) onGestureEnd(pointer) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/ui/widget/properties/ExposedSelectionMenu.kt ================================================ package cn.netdiscovery.monica.ui.widget.properties import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp /** * * @FileName: * cn.netdiscovery.monica.ui.widget.properties.ExposedSelectionMenu * @author: Tony Shen * @date: 2024/11/26 13:08 * @version: V1.0 <描述当前版本功能> */ /** * Expandable selection menu * @param title of the displayed item on top * @param index index of selected item * @param options list of [String] options * @param onSelected lambda to be invoked when an item is selected that returns * its index. */ @OptIn(ExperimentalMaterialApi::class) @Composable fun ExposedSelectionMenu( title: String, index: Int, options: List, onSelected: (Int) -> Unit ) { var expanded by remember { mutableStateOf(false) } var selectedOptionText by remember { mutableStateOf(options[index]) } var selectedIndex = remember { index } ExposedDropdownMenuBox( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), expanded = expanded, onExpandedChange = { expanded = !expanded } ) { TextField( modifier = Modifier.fillMaxWidth(), readOnly = true, value = selectedOptionText, onValueChange = { }, label = { Text(title) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon( expanded = expanded ) }, colors = ExposedDropdownMenuDefaults.textFieldColors( backgroundColor = Color.White, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent, ) ) ExposedDropdownMenu( modifier = Modifier.fillMaxWidth(), expanded = expanded, onDismissRequest = { expanded = false } ) { options.forEachIndexed { index: Int, selectionOption: String -> DropdownMenuItem( modifier = Modifier.fillMaxWidth(), onClick = { selectedOptionText = selectionOption expanded = false selectedIndex = index onSelected(selectedIndex) } ) { Text(text = selectionOption) } } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/AppDirs.kt ================================================ package cn.netdiscovery.monica.utils import Monica.config.BuildConfig import cn.netdiscovery.monica.config.* import java.io.File /** * * @FileName: * cn.netdiscovery.monica.utils.AppDirs * @author: Tony Shen * @date: 2025/6/2 16:08 * @version: V1.0 <描述当前版本功能> */ object AppDirs { private const val appName = "Monica" val cacheDir: File by lazy { val path = when { isMac -> { if (BuildConfig.IS_PRO_VERSION) { "$userHome/Library/Caches/$appName/rxcache" } else { "$workDirectory/rxcache" } } isLinux -> { if (BuildConfig.IS_PRO_VERSION) { "$userHome/.cache/$appName" } else { "$workDirectory/rxcache" } } isWindows -> { if (BuildConfig.IS_PRO_VERSION) { "${getWindowsAppData()}/$appName/Cache" } else { "$workDirectory/rxcache" } } else -> "$userHome/.cache/$appName" } createDir(path) } val logDir: File by lazy { val path = when { isMac -> { if (BuildConfig.IS_PRO_VERSION) { "$userHome/Library/Logs/$appName" } else { "$workDirectory/log" } } isLinux -> { if (BuildConfig.IS_PRO_VERSION) { "$userHome/.local/share/$appName/logs" } else { "$workDirectory/log" } } isWindows -> { if (BuildConfig.IS_PRO_VERSION) { "${getWindowsAppData()}/$appName/Logs" } else { "$workDirectory/log" } } else -> "$userHome/.local/share/$appName/logs" } createDir(path) } private fun getWindowsAppData(): String { return System.getenv("APPDATA") ?: "$userHome/AppData/Roaming" } private fun createDir(path: String): File { val file = File(path) if (!file.exists()) { file.mkdirs() } return file } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/ButtonUtils.kt ================================================ package cn.netdiscovery.monica.utils import loadingDisplay /** * * @FileName: * cn.netdiscovery.monica.utils.ButtonUtils * @author: Tony Shen * @date: 2024/4/27 17:16 * @version: V1.0 <描述当前版本功能> */ /** * 点击按钮后,会带有 loading 的效果 */ fun loadingDisplay(block: Action) { loadingDisplay = true block.invoke() loadingDisplay = false } /** * 点击按钮后,会带有 loading 的效果 */ suspend fun loadingDisplayWithSuspend(block:suspend ()->Unit) { loadingDisplay = true block.invoke() loadingDisplay = false } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/DebugUtils.kt ================================================ package cn.netdiscovery.monica.utils import kotlin.system.measureTimeMillis /** * * @FileName: * cn.netdiscovery.monica.utils.DebugUtils * @author: Tony Shen * @date: 2024/4/30 12:43 * @version: V1.0 调试时,使用的工具类 */ /** * 统计耗时任务的时间,便于调试时使用 */ fun measure(block: () -> Unit):Long { val timeCost = measureTimeMillis { block.invoke() } return timeCost } /** * 统计耗时任务的时间,便于调试时使用 */ suspend fun measureWithSuspend(block: suspend() -> Unit):Long { val timeCost = measureTimeMillis { block.invoke() } return timeCost } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/FileChoose.kt ================================================ package cn.netdiscovery.monica.utils import androidx.compose.ui.awt.ComposeWindow import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.extensions.launchWithLoading import cn.netdiscovery.monica.i18n.LocalizationManager import org.slf4j.Logger import org.slf4j.LoggerFactory import java.awt.datatransfer.DataFlavor import java.awt.dnd.DnDConstants import java.awt.dnd.DropTarget import java.awt.dnd.DropTargetDropEvent import java.io.File import javax.swing.JFileChooser import javax.swing.SwingUtilities import javax.swing.UIManager import javax.swing.filechooser.FileNameExtensionFilter /** * * @FileName: * cn.netdiscovery.monica.utils.FileChoose * @author: Tony Shen * @date: 2024/4/26 10:57 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) val legalSuffixList: Array = arrayOf("jpg", "jpeg", "png","webp","svg", "hdr", "CR2", "CR3", "HEIC") fun chooseImage(state: ApplicationState, block:(file: File)->Unit) { showFileSelector( isMultiSelection = false, selectionMode = JFileChooser.FILES_ONLY, onFileSelected = { state.scope.launchWithLoading { val file = it.getOrNull(0) if (file != null) { logger.info("load file: ${file.absolutePath}") block.invoke(file) } } } ) } private fun showFileSelector( suffixList: Array = legalSuffixList, isMultiSelection: Boolean = true, selectionMode: Int = JFileChooser.FILES_AND_DIRECTORIES, // 可以选择目录和文件 selectionFileFilter: FileNameExtensionFilter? = FileNameExtensionFilter("图片(${legalSuffixList.contentToString()})", *suffixList), // 文件过滤 onFileSelected: (Array) -> Unit ) { JFileChooser().apply { try { val lookAndFeel = UIManager.getSystemLookAndFeelClassName() UIManager.setLookAndFeel(lookAndFeel) SwingUtilities.updateComponentTreeUI(this) } catch (e: Throwable) { e.printStackTrace() } fileSelectionMode = selectionMode isMultiSelectionEnabled = isMultiSelection fileFilter = selectionFileFilter val result = showOpenDialog(ComposeWindow()) if (result == JFileChooser.APPROVE_OPTION) { if (isMultiSelection) { onFileSelected(this.selectedFiles) } else { val resultArray = arrayOf(this.selectedFile) onFileSelected(resultArray) } } } } fun exportImage( onFileSelected: (JFileChooser) -> Unit ) { JFileChooser().apply { this.dialogTitle = LocalizationManager.getString("export_image") // 添加格式选项 val pngFilter = FileNameExtensionFilter("PNG 图像 (*.png)", "png") val jpgFilter = FileNameExtensionFilter("JPG 图像 (*.jpg)", "jpg") val webpFilter = FileNameExtensionFilter("Webp 图像 (*.webp)", "webp") this.addChoosableFileFilter(pngFilter) this.addChoosableFileFilter(jpgFilter) this.addChoosableFileFilter(webpFilter) this.fileFilter = pngFilter // 默认选择 PNG val result = this.showSaveDialog(null) if (result == JFileChooser.APPROVE_OPTION) { onFileSelected(this) } } } fun dropFileTarget( onFileDrop: (List) -> Unit ): DropTarget { return object : DropTarget() { override fun drop(event: DropTargetDropEvent) { event.acceptDrop(DnDConstants.ACTION_REFERENCE) val dataFlavors = event.transferable.transferDataFlavors dataFlavors.forEach { if (it == DataFlavor.javaFileListFlavor) { val list = event.transferable.getTransferData(it) as List<*> val pathList = mutableListOf() list.forEach { filePath -> pathList.add(filePath.toString()) } onFileDrop(pathList) } } event.dropComplete(true) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/IOUtils.kt ================================================ package cn.netdiscovery.monica.utils import java.io.Closeable import java.io.IOException /** * * @FileName: * cn.netdiscovery.monica.utils.IOUtils * @author: Tony Shen * @date: 2024/5/2 21:47 * @version: V1.0 <描述当前版本功能> */ /** * 安全关闭io流 * @param closeable */ fun closeQuietly(closeable: Closeable?) { if (closeable != null) { try { closeable.close() } catch (e: IOException) { e.printStackTrace() } } } /** * 安全关闭io流 * @param closeables */ fun closeQuietly(vararg closeables: Closeable?) { if (closeables.isNotEmpty()) { for (closeable in closeables) { closeQuietly(closeable) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/ImageCompressionUtils.kt ================================================ package cn.netdiscovery.monica.utils import org.slf4j.Logger import org.slf4j.LoggerFactory import java.awt.Graphics2D import java.awt.RenderingHints import java.awt.image.BufferedImage import java.io.ByteArrayOutputStream import java.io.File import javax.imageio.ImageIO import javax.imageio.ImageWriteParam import javax.imageio.ImageWriter /** * 图像压缩工具类 * 支持多种压缩算法:JPEG Quality、PNG Optimization、WebP Lossy、WebP Lossless * * @author: Tony Shen * @date: 2025/12/07 * @version: V1.0 */ private val logger: Logger = LoggerFactory.getLogger(ImageCompressionUtils::class.java) /** * 压缩算法枚举 */ enum class CompressionAlgorithm(val displayName: String, val format: String) { JPEG_QUALITY("JPEG Quality", "jpg"), PNG_OPTIMIZATION("PNG Optimization", "png"), WEBP_LOSSY("WebP Lossy", "webp"), WEBP_LOSSLESS("WebP Lossless", "webp") } /** * 压缩参数数据类 */ data class CompressionParams( val algorithm: CompressionAlgorithm = CompressionAlgorithm.JPEG_QUALITY, val quality: Float = 0.8f, // 0.0 - 1.0,用于 JPEG 和 WebP Lossy val compressionLevel: Int = 9 // 0 - 9,用于 PNG 和 WebP Lossless ) { init { require(quality in 0f..1f) { "Quality must be between 0 and 1" } require(compressionLevel in 0..9) { "Compression level must be between 0 and 9" } } } object ImageCompressionUtils { /** * 检查系统是否支持 WebP 格式 */ fun isWebPSupported(): Boolean { return try { val readers = ImageIO.getImageReadersByFormatName("webp") readers.hasNext() } catch (e: Exception) { false } } /** * 获取 WebP 降级后的格式 */ fun getWebPFallbackFormat(isLossy: Boolean): String { return if (isLossy) "JPEG" else "PNG" } /** * 压缩单张图片 * * @param image BufferedImage 图像对象 * @param params 压缩参数 * @return 压缩后的字节数组,失败返回 null */ /** * 压缩单张图片 * * @param image BufferedImage 图像对象 * @param params 压缩参数 * @return Pair<压缩后的字节数组, 是否使用了降级处理>,失败返回 null */ fun compressImage(image: BufferedImage, params: CompressionParams): Pair? { return try { val outputStream = ByteArrayOutputStream() var usedFallback = false when (params.algorithm) { CompressionAlgorithm.JPEG_QUALITY -> { compressJPEG(image, params.quality, outputStream) } CompressionAlgorithm.PNG_OPTIMIZATION -> { compressPNG(image, params.compressionLevel, outputStream) } CompressionAlgorithm.WEBP_LOSSY -> { usedFallback = compressWebP(image, params.quality, true, outputStream) } CompressionAlgorithm.WEBP_LOSSLESS -> { usedFallback = compressWebP(image, 1f, false, outputStream) } } Pair(outputStream.toByteArray(), usedFallback) } catch (e: Exception) { logger.error("Image compression failed", e) null } } /** * 压缩单张图片(兼容旧接口) */ @Deprecated("使用 compressImage 替代,可以获取降级信息") fun compressImageLegacy(image: BufferedImage, params: CompressionParams): ByteArray? { return compressImage(image, params)?.first } /** * 压缩 JPEG 图片 */ private fun compressJPEG(image: BufferedImage, quality: Float, outputStream: ByteArrayOutputStream) { val writer: ImageWriter? = ImageIO.getImageWritersByFormatName("jpg").next() if (writer != null) { val param = writer.defaultWriteParam var compressionApplied = false if (param.canWriteCompressed()) { try { param.compressionMode = ImageWriteParam.MODE_EXPLICIT if (param.compressionTypes.isNotEmpty()) { param.compressionType = param.compressionTypes[0] } param.compressionQuality = quality compressionApplied = true } catch (e: Exception) { // Compression parameters cannot be applied, use default compression } } val imageOutput = ImageIO.createImageOutputStream(outputStream) writer.output = imageOutput writer.write(null, javax.imageio.IIOImage(image, null, null), param) writer.dispose() imageOutput?.close() } else { // Fallback: use default JPEG output ImageIO.write(image, "jpg", outputStream) } } /** * 压缩 PNG 图片 * PNG 使用 Deflate 压缩,compressionLevel 控制压缩级别 * compressionLevel: 0 = 最低压缩(快速),9 = 最高压缩(慢速) * * 注意:Java ImageIO 对 PNG 压缩的支持有限,某些实现可能不支持压缩参数 * 如果无法应用压缩参数,会使用默认的 PNG 输出(可能压缩效果较差) * * 优化:对于从 JPG 等有损格式转换来的图片,优化 BufferedImage 类型以提高压缩效率 */ private fun compressPNG(image: BufferedImage, compressionLevel: Int, outputStream: ByteArrayOutputStream) { // 优化图片类型以提高压缩效率 // 如果图片不是标准的 RGB/ARGB 类型,转换为标准类型可以减少文件大小 val optimizedImage = if (image.type != BufferedImage.TYPE_INT_RGB && image.type != BufferedImage.TYPE_INT_ARGB && image.type != BufferedImage.TYPE_3BYTE_BGR) { // 检查是否有透明通道 val hasAlpha = image.colorModel.hasAlpha() val targetType = if (hasAlpha) BufferedImage.TYPE_INT_ARGB else BufferedImage.TYPE_INT_RGB // 创建优化后的图片 val converted = BufferedImage(image.width, image.height, targetType) val g: Graphics2D = converted.createGraphics() as Graphics2D // 使用高质量渲染 g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) g.drawImage(image, 0, 0, null) g.dispose() converted } else { image } val writer: ImageWriter? = ImageIO.getImageWritersByFormatName("png").next() if (writer != null) { val param = writer.defaultWriteParam // 尝试设置压缩参数 var compressionApplied = false if (param.canWriteCompressed()) { try { param.compressionMode = ImageWriteParam.MODE_EXPLICIT if (param.compressionTypes.isNotEmpty()) { param.compressionType = param.compressionTypes[0] } // PNG 压缩级别 0-9,9 是最高压缩率 // ImageIO 的 compressionQuality 值越高压缩率越高,所以直接使用 compressionLevel / 9f param.compressionQuality = if (compressionLevel == 0) 0f else compressionLevel / 9f compressionApplied = true } catch (e: Exception) { // Compression parameters cannot be applied, use default compression } } val imageOutput = ImageIO.createImageOutputStream(outputStream) writer.output = imageOutput writer.write(null, javax.imageio.IIOImage(optimizedImage, null, null), param) writer.dispose() imageOutput?.close() } else { // Fallback: use default PNG output ImageIO.write(optimizedImage, "png", outputStream) } } /** * 压缩 WebP 图片 * 注意:Java 内置不支持 WebP,需要外部库支持 * 这里采用降级处理,转换为 JPEG 或 PNG * * @return 是否使用了降级处理(true = 降级,false = 原生 WebP) */ private fun compressWebP( image: BufferedImage, quality: Float, isLossy: Boolean, outputStream: ByteArrayOutputStream ): Boolean { // 检查是否支持 WebP if (!isWebPSupported()) { // WebP requires third-party library support (e.g., webp-imageio), fallback for now if (isLossy) { // WebP Lossy fallback to JPEG compressJPEG(image, quality, outputStream) } else { // WebP Lossless fallback to PNG compressPNG(image, 9, outputStream) } return true // Return true to indicate fallback was used } // If WebP is supported, try to use native WebP encoding // Note: WebP encoding library support is required here // Still using fallback for now if (isLossy) { compressJPEG(image, quality, outputStream) } else { compressPNG(image, 9, outputStream) } return true } /** * 压缩并保存图片到文件 * * @param image BufferedImage 图像对象 * @param outputFile 输出文件路径 * @param params 压缩参数 * @return 压缩后的文件大小(字节),失败返回 -1 */ /** * 压缩并保存图片到文件 * * @param image BufferedImage 图像对象 * @param outputFile 输出文件路径 * @param params 压缩参数 * @param originalFile 原始文件(可选,用于格式检测) * @return Pair<压缩后的文件大小(字节), 是否使用了降级处理>,失败返回 null */ data class SaveResult( val outputFile: File, val sizeBytes: Long, val usedFallback: Boolean ) private fun resolveActualExtension(params: CompressionParams, usedFallback: Boolean): String { return when (params.algorithm) { CompressionAlgorithm.WEBP_LOSSY -> if (usedFallback) "jpg" else "webp" CompressionAlgorithm.WEBP_LOSSLESS -> if (usedFallback) "png" else "webp" else -> params.algorithm.format } } fun saveCompressedData( outputFile: File, params: CompressionParams, compressedData: ByteArray, usedFallback: Boolean ): SaveResult { val ext = resolveActualExtension(params, usedFallback) val baseName = outputFile.nameWithoutExtension.ifBlank { "compressed" } val finalFile = File(outputFile.parentFile ?: File("."), "$baseName.$ext") finalFile.writeBytes(compressedData) return SaveResult( outputFile = finalFile, sizeBytes = compressedData.size.toLong(), usedFallback = usedFallback ) } fun compressAndSaveImage( image: BufferedImage, outputFile: File, params: CompressionParams, originalFile: File? = null ): SaveResult? { return try { val result = compressImage(image, params) ?: return null val (compressedData, usedFallback) = result saveCompressedData( outputFile = outputFile, params = params, compressedData = compressedData, usedFallback = usedFallback ) } catch (e: Exception) { logger.error("Failed to save compressed image", e) null } } /** * 检测格式转换是否可能导致文件变大 * * @param originalFile 原始文件 * @param targetAlgorithm 目标压缩算法 * @return 如果转换可能导致文件变大,返回警告信息的 key,否则返回 null */ fun checkFormatConversionWarning( originalFile: File?, targetAlgorithm: CompressionAlgorithm ): String? { if (originalFile == null) return null val originalFormat = ImageFormatDetector.detectFormat(originalFile) return when { // JPG 转 PNG:PNG 是无损格式,通常比 JPG 大 originalFormat == ImageFormat.JPEG && targetAlgorithm == CompressionAlgorithm.PNG_OPTIMIZATION -> { "format_conversion_warning_jpg_to_png" } // JPG 转 WebP Lossless:同样可能变大 originalFormat == ImageFormat.JPEG && targetAlgorithm == CompressionAlgorithm.WEBP_LOSSLESS -> { "format_conversion_warning_jpg_to_webp_lossless" } else -> null } } /** * 压缩并保存图片到文件(兼容旧接口) */ @Deprecated("使用 compressAndSaveImage 替代,可以获取降级信息") fun compressAndSaveImageLegacy( image: BufferedImage, outputFile: File, params: CompressionParams ): Long { return compressAndSaveImage(image, outputFile, params)?.sizeBytes ?: -1 } /** * 批量压缩文件夹中的所有图片 * * @param sourceDir 源文件夹 * @param outputDir 输出文件夹 * @param params 压缩参数 * @return 成功压缩的文件数 */ fun compressBatch( sourceDir: File, outputDir: File, params: CompressionParams ): Int { return try { if (!sourceDir.isDirectory) { logger.error("Source path is not a directory: ${sourceDir.absolutePath}") return 0 } if (!outputDir.exists()) { outputDir.mkdirs() } val imageExtensions = setOf("jpg", "jpeg", "png", "bmp", "gif", "tiff") val imageFiles = sourceDir.listFiles { file -> file.isFile && file.extension.lowercase() in imageExtensions } ?: emptyArray() var successCount = 0 imageFiles.forEach { file -> try { val image = ImageIO.read(file) ?: return@forEach // 生成输出文件名 val baseName = file.nameWithoutExtension val outputFileName = "$baseName.${params.algorithm.format}" val outputFile = File(outputDir, outputFileName) // 压缩并保存 val result = compressAndSaveImage(image, outputFile, params) if (result != null) { successCount++ } } catch (e: Exception) { logger.error("Failed to process image: ${file.absolutePath}", e) } } successCount } catch (e: Exception) { logger.error("Batch compression error", e) 0 } } /** * 计算压缩效果 * * @param originalSize 原始大小(字节) * @param compressedSize 压缩后大小(字节) * @return 压缩率百分比 */ fun calculateCompressionRatio(originalSize: Long, compressedSize: Long): Int { return if (originalSize > 0) { (100 * (1 - compressedSize.toDouble() / originalSize)).toInt() } else { 0 } } /** * 格式化文件大小为可读的字符串 */ fun formatFileSize(bytes: Long): String { return when { bytes <= 0 -> "0 B" bytes < 1024 -> "$bytes B" bytes < 1024 * 1024 -> "${bytes / 1024} KB" bytes < 1024 * 1024 * 1024 -> "${String.format("%.2f", bytes / (1024.0 * 1024.0))} MB" else -> "${String.format("%.2f", bytes / (1024.0 * 1024.0 * 1024.0))} GB" } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/ImageFormatDetector.kt ================================================ package cn.netdiscovery.monica.utils import java.io.File import java.io.FileInputStream import java.nio.charset.StandardCharsets /** * * @FileName: * cn.netdiscovery.monica.utils.ImageFormatDetector * @author: Tony Shen * @date: 2025/6/4 16:29 * @version: V1.0 基于文件的文件头,来判断文件的格式 */ enum class ImageFormat { JPEG, PNG, WEBP, HEIC, AVIF, BMP, TIFF, GIF, PSD, HDR, SVG, CR2, CR3, ARW, NEF, ORF, RAF, RW2, DNG, UNKNOWN } fun ImageFormat.isRaw(): Boolean = this in listOf( ImageFormat.CR2, ImageFormat.CR3, ImageFormat.ARW, ImageFormat.NEF, ImageFormat.RAF, ImageFormat.ORF, ImageFormat.RW2, ImageFormat.DNG ) object ImageFormatDetector { fun detectFormat(file: File): ImageFormat { val header = readFileHeader(file, 32) return when { // JPEG header.startsWith(0xFF, 0xD8, 0xFF) -> ImageFormat.JPEG // PNG header.startsWith("89504E470D0A1A0A") -> ImageFormat.PNG // WEBP (RIFFxxxxWEBP) header.startsWith("52494646") && header.slice(8, 12).toAscii() == "WEBP" -> ImageFormat.WEBP // HEIC header.slice(4, 12).toAscii().startsWith("ftypheic") -> ImageFormat.HEIC // AVIF header.slice(4, 12).toAscii().startsWith("ftypavif") -> ImageFormat.AVIF // BMP header.startsWith("424D") -> ImageFormat.BMP // GIF header.startsWith("47494638") -> ImageFormat.GIF // PSD header.startsWith("38425053") -> ImageFormat.PSD // HDR (ASCII header) header.startsWith("#?R".toByteArray(StandardCharsets.US_ASCII)) -> ImageFormat.HDR // SVG (text-based, starts with ImageFormat.SVG // Canon CR2 header.startsWith("49492A00") && header.size >= 12 && header.slice(8, 10).contentEquals("CR".toByteArray()) -> ImageFormat.CR2 // Canon CR3 (ISO BMFF format with ftypcrx) header.slice(4, 12).toAscii().startsWith("ftypcrx") -> ImageFormat.CR3 // Sony ARW header.startsWith("49492A00") && header.slice(8, 12).contentEquals("ARW ".toByteArray()) -> ImageFormat.ARW // Nikon NEF header.startsWith("4D4D002A") && header.slice(8, 12).contentEquals("NEF".toByteArray()) -> ImageFormat.NEF // Olympus ORF header.slice(0, 4).contentEquals("IIRO".toByteArray()) -> ImageFormat.ORF // Panasonic RW2 header.startsWith("49492A00") && header.slice(8, 12).contentEquals("RW2".toByteArray()) -> ImageFormat.RW2 // Fuji RAF header.startsWith("4655494A") -> ImageFormat.RAF // DNG header.startsWith("49492A00") && header.containsSubsequence("Adobe".toByteArray()) -> ImageFormat.DNG // TIFF fallback header.startsWith("49492A00") || header.startsWith("4D4D002A") -> ImageFormat.TIFF else -> ImageFormat.UNKNOWN } } fun getImageFormat(file: File): String? { val imageFormat = detectFormat(file) return if (imageFormat!=ImageFormat.UNKNOWN) { imageFormat.name.lowercase() } else { null } } // 读取文件前 N 字节作为头部 private fun readFileHeader(file: File, size: Int): ByteArray { FileInputStream(file).use { input -> return input.readNBytes(size) } } // 二进制 startsWith 判断 private fun ByteArray.startsWith(vararg bytes: Int): Boolean { if (this.size < bytes.size) return false for (i in bytes.indices) { if (this[i].toInt() and 0xFF != bytes[i]) return false } return true } // Hex 字符串形式 startsWith 判断 private fun ByteArray.startsWith(hex: String): Boolean { val bytes = hex.chunked(2).map { it.toInt(16) } return startsWith(*bytes.toIntArray()) } // ByteArray 对比 private fun ByteArray.startsWith(prefix: ByteArray): Boolean { if (this.size < prefix.size) return false for (i in prefix.indices) { if (this[i] != prefix[i]) return false } return true } // ByteArray 区间切片 private fun ByteArray.slice(from: Int, to: Int): ByteArray { return copyOfRange(from, to.coerceAtMost(this.size)) } // 判断是否包含某一子序列 private fun ByteArray.containsSubsequence(seq: ByteArray): Boolean { outer@ for (i in 0..(this.size - seq.size)) { for (j in seq.indices) { if (this[i + j] != seq[j]) continue@outer } return true } return false } // 将 ByteArray 转成 ASCII 字符串(安全用于文件头分析) private fun ByteArray.toAscii(): String = String(this, Charsets.US_ASCII) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/ImageUtils.kt ================================================ package cn.netdiscovery.monica.utils import androidx.compose.ui.graphics.toAwtImage import androidx.compose.ui.graphics.toComposeImageBitmap import cn.netdiscovery.monica.exception.MonicaException import cn.netdiscovery.monica.imageprocess.BufferedImages import cn.netdiscovery.monica.imageprocess.filter.* import cn.netdiscovery.monica.imageprocess.filter.blur.* import cn.netdiscovery.monica.imageprocess.filter.sharpen.LaplaceSharpenFilter import cn.netdiscovery.monica.imageprocess.filter.sharpen.SharpenFilter import cn.netdiscovery.monica.imageprocess.filter.sharpen.USMFilter import cn.netdiscovery.monica.imageprocess.utils.extension.convertToRGB import cn.netdiscovery.monica.imageprocess.utils.loadFixedSvgAsImage import cn.netdiscovery.monica.opencv.ImageProcess import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.extensions.printConstructorParamsWithValues import com.safframework.kotlin.coroutines.IO import kotlinx.coroutines.withContext import org.slf4j.Logger import org.slf4j.LoggerFactory import java.awt.image.BufferedImage import java.io.File import javax.imageio.ImageIO /** * * @FileName: * cn.netdiscovery.monica.imageprocess.ImageUtils * @author: Tony Shen * @date: 2024/4/26 22:11 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) fun getBufferedImage(file: File, state: ApplicationState): BufferedImage { val filePath = file.absolutePath val imageFormat = ImageFormatDetector.detectFormat(file) logger.info("format: $imageFormat") if (imageFormat.isRaw()) { try { val decodedPreviewImage = ImageProcess.decodeRawToBufferForPreView(filePath) if (decodedPreviewImage!=null) { state.nativeImageInfo = decodedPreviewImage state.rawImageFormat = imageFormat val outPixels = decodedPreviewImage.previewImage val width = decodedPreviewImage.width val height = decodedPreviewImage.height val image = BufferedImages.toBufferedImage(outPixels, width, height, BufferedImage.TYPE_INT_ARGB) return image } else { throw MonicaException("Image format is not supported") } } catch (e:Exception) { logger.error("decode raw image failed", e) throw MonicaException("decode raw image failed") } } else { return when(imageFormat) { ImageFormat.SVG -> loadFixedSvgAsImage(file) ?: ImageIO.read(file) ImageFormat.HDR -> { ImageIO.read(file).convertToRGB() } ImageFormat.JPEG, ImageFormat.PNG, ImageFormat.WEBP -> { ImageIO.read(file) } ImageFormat.HEIC -> { try { val decodedPreviewImage = ImageProcess.decodeHeif(filePath) if (decodedPreviewImage!=null) { state.nativeImageInfo = decodedPreviewImage state.rawImageFormat = imageFormat val outPixels = decodedPreviewImage.previewImage val width = decodedPreviewImage.width val height = decodedPreviewImage.height val image = BufferedImages.toBufferedImage(outPixels, width, height, BufferedImage.TYPE_INT_ARGB) return image } else { throw MonicaException("Image format is not supported") } } catch (e: Exception) { logger.error("decode heif image failed", e) throw MonicaException("decode heif image failed") } } else -> throw MonicaException("Unsupported image format: $imageFormat") } } } fun getBufferedImage(file: File): BufferedImage { val filePath = file.absolutePath val imageFormat = ImageFormatDetector.detectFormat(file) logger.info("format: $imageFormat") if (imageFormat.isRaw()) { val decodedPreviewImage = ImageProcess.decodeRawToBufferForPreView(filePath) if (decodedPreviewImage!=null) { val outPixels = decodedPreviewImage.previewImage val width = decodedPreviewImage.width val height = decodedPreviewImage.height val image = BufferedImages.toBufferedImage(outPixels,width,height,BufferedImage.TYPE_INT_ARGB) return image } else { throw MonicaException("Image format is not supported") } } else { return when(imageFormat) { ImageFormat.SVG -> loadFixedSvgAsImage(file) ?: ImageIO.read(file) ImageFormat.HDR -> { ImageIO.read(file).convertToRGB() } ImageFormat.JPEG, ImageFormat.PNG, ImageFormat.WEBP -> { ImageIO.read(file) } ImageFormat.HEIC -> { val decodedPreviewImage = ImageProcess.decodeHeif(filePath) if (decodedPreviewImage!=null) { val outPixels = decodedPreviewImage.previewImage val width = decodedPreviewImage.width val height = decodedPreviewImage.height val image = BufferedImages.toBufferedImage(outPixels, width, height, BufferedImage.TYPE_INT_ARGB) return image } else { throw MonicaException("Image format is not supported") } } else -> throw MonicaException("Unsupported image format: $imageFormat") } } } suspend fun doFilter( filterName: String, array: MutableList, image: BufferedImage ): BufferedImage { return withContext(IO) { when(filterName) { "AverageFilter" -> { AverageFilter().transform(image) } "BilateralFilter" -> { val filter = BilateralFilter(array[0] as Double, array[1] as Double) filter.printConstructorParamsWithValues() filter.transform(image) } "BlockFilter" -> { val filter = BlockFilter(array[0] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "BoxBlurFilter" -> { val filter = BoxBlurFilter(array[0] as Int,array[2] as Int,array[1] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "BumpFilter" -> { BumpFilter().transform(image) } "CarveFilter" -> { val filter = CarveFilter() filter.transform(image) } "ColorFilter" -> { val filter = ColorFilter(array[0] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "ColorHalftoneFilter" -> { val filter = ColorHalftoneFilter(array[0] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "ConBriFilter" -> { val filter = ConBriFilter(array[1] as Float,array[0] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "CropFilter" -> { val filter = CropFilter(array[2] as Int,array[3] as Int,array[1] as Int,array[0] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "CrystallizeFilter" -> { val filter = CrystallizeFilter(array[0] as Float, array[3] as Float, array[2] as Float, array[1] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "DiffuseFilter" -> { val filter = DiffuseFilter(array[0] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "EmbossFilter" -> { val filter = EmbossFilter(array[0] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "EqualizeFilter" -> { val filter = EqualizeFilter() filter.transform(image) } "ExposureFilter" -> { val filter = ExposureFilter(array[0] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "GainFilter" -> { val filter = GainFilter(array[1] as Float, array[0] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "GammaFilter" -> { val filter = GammaFilter(array[0] as Double) filter.printConstructorParamsWithValues() filter.transform(image) } "FastBlur2D" -> { FastBlur2D(array[0] as Int).transform(image) } "GaussianFilter" -> { val filter = GaussianFilter(array[0] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "GaussianNoiseFilter" -> { val filter = GaussianNoiseFilter(array[0] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "GradientFilter" -> { GradientFilter().transform(image) } "GrayFilter" -> { GrayFilter().transform(image) } "HighPassFilter" -> { val filter = HighPassFilter(array[0] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "HSBAdjustFilter" -> { val filter = HSBAdjustFilter(array[1] as Float, array[2] as Float, array[0] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "InvertFilter" -> { InvertFilter().transform(image) } "LaplaceSharpenFilter" -> { LaplaceSharpenFilter().transform(image) } "LensBlurFilter" -> { val filter = LensBlurFilter(array[3] as Float,array[1] as Float,array[2] as Float,array[0] as Float,array[4] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "MarbleFilter" -> { val filter = MarbleFilter(array[1] as Float,array[2] as Float,array[0] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "MaximumFilter" -> { val filter = MaximumFilter() filter.printConstructorParamsWithValues() filter.transform(image) } "MinimumFilter" -> { val filter = MinimumFilter() filter.printConstructorParamsWithValues() filter.transform(image) } "MirrorFilter" -> { val filter = MirrorFilter(array[2] as Float,array[0] as Float,array[1] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "MosaicFilter" -> { val filter = MosaicFilter(array[0] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "MotionFilter" -> { val filter = MotionFilter(array[1] as Float,array[0] as Float,array[2] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "NatureFilter" -> { val filter = NatureFilter(array[0] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "OffsetFilter" -> { val filter = OffsetFilter(array[0] as Int,array[1] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "OilPaintFilter" -> { val filter = OilPaintFilter(array[1] as Int,array[0] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "PointillizeFilter" -> { val filter = PointillizeFilter(array[0] as Float, array[1] as Float, array[4] as Float, array[3] as Float, array[2] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "PosterizeFilter" -> { val filter = PosterizeFilter(array[0] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "RippleFilter" -> { val filter = RippleFilter(array[1] as Float, array[3] as Float, array[2] as Float, array[4] as Float, array[0] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "SepiaToneFilter" -> { SepiaToneFilter().transform(image) } "SharpenFilter" -> { SharpenFilter().transform(image) } "SmearFilter" -> { val filter = SmearFilter(array[0] as Float, array[1] as Float, array[2] as Int, array[4] as Int, array[3] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "SolarizeFilter" -> { SolarizeFilter().transform(image) } "SpotlightFilter" -> { val filter = SpotlightFilter(array[0] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "StrokeAreaFilter" -> { val filter = StrokeAreaFilter(array[0] as Double) filter.printConstructorParamsWithValues() filter.transform(image) } "SwimFilter" -> { val filter = SwimFilter(array[2] as Float,array[3] as Float,array[1] as Float,array[0] as Float,array[5] as Float,array[4] as Float,) filter.printConstructorParamsWithValues() filter.transform(image) } "USMFilter" -> { val filter = USMFilter(array[1] as Float,array[0] as Float,array[2] as Int) filter.printConstructorParamsWithValues() filter.transform(image.toComposeImageBitmap().toAwtImage()) } "VariableBlurFilter"-> { val filter = VariableBlurFilter(array[0] as Int,array[2] as Int,array[1] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "VignetteFilter"-> { val filter = VignetteFilter(array[0] as Int,array[1] as Int) filter.printConstructorParamsWithValues() filter.transform(image) } "WaterFilter" -> { val filter = WaterFilter(array[5] as Float, array[0] as Float, array[3] as Float, array[1] as Float, array[2] as Float, array[4] as Float) filter.printConstructorParamsWithValues() filter.transform(image) } "WhiteImageFilter" -> { val filter = WhiteImageFilter(array[0] as Double) filter.printConstructorParamsWithValues() filter.transform(image) } else -> { image } } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/LogHomeProperty.kt ================================================ package cn.netdiscovery.monica.utils import ch.qos.logback.core.PropertyDefinerBase import java.io.File /** * * @FileName: * cn.netdiscovery.monica.utils.LogHomeProperty * @author: Tony Shen * @date: 2022/4/21 4:23 下午 * @version: V1.0 <描述当前版本功能> */ class LogHomeProperty : PropertyDefinerBase() { override fun getPropertyValue(): String { return AppDirs.logDir.absolutePath + File.separator } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/LogUtils.kt ================================================ package cn.netdiscovery.monica.utils import org.slf4j.Logger import org.slf4j.LoggerFactory /** * * @FileName: * cn.netdiscovery.monica.utils.LogUtils * @author: Tony Shen * @date: 2024/7/10 14:03 * @version: V1.0 <描述当前版本功能> */ inline fun logger(): Logger = LoggerFactory.getLogger(T::class.java) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/ScreenshotUtils.kt ================================================ package cn.netdiscovery.monica.utils import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.extensions.launchWithLoading import org.slf4j.Logger import org.slf4j.LoggerFactory import java.awt.Rectangle import java.awt.Robot import java.awt.Toolkit import java.awt.image.BufferedImage /** * 截图工具类 * * @author: Tony Shen * @date: 2025/12/03 * @version: V1.0 */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) /** * 截取整个屏幕 */ fun captureFullScreen(): BufferedImage? { return try { val robot = Robot() val screenSize = Toolkit.getDefaultToolkit().screenSize robot.createScreenCapture(Rectangle(0, 0, screenSize.width, screenSize.height)) } catch (e: Exception) { logger.error("截取全屏失败", e) null } } /** * 截取指定区域 * @param x 起始 X 坐标 * @param y 起始 Y 坐标 * @param width 宽度 * @param height 高度 */ fun captureRegion(x: Int, y: Int, width: Int, height: Int): BufferedImage? { return try { val robot = Robot() robot.createScreenCapture(Rectangle(x, y, width, height)) } catch (e: Exception) { logger.error("截取区域失败: x=$x, y=$y, width=$width, height=$height", e) null } } /** * 将截图加载到 ApplicationState */ fun loadScreenshotToState(state: ApplicationState, screenshot: BufferedImage) { state.scope.launchWithLoading { try { logger.info("加载截图到应用状态") state.rawImage = screenshot state.currentImage = state.rawImage state.rawImageFile = null } catch (e: Exception) { logger.error("加载截图失败", e) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/TextUtils.kt ================================================ package cn.netdiscovery.monica.utils import java.text.Collator import java.util.* /** * * @FileName: * cn.netdiscovery.monica.utils.TextUtils * @author: Tony Shen * @date: 2024/5/11 14:05 * @version: V1.0 <描述当前版本功能> */ val collator:Collator by lazy { Collator.getInstance(Locale.UK) } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/TimeUtils.kt ================================================ package cn.netdiscovery.monica.utils import java.text.SimpleDateFormat import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.Locale /** * * @FileName: * cn.netdiscovery.monica.utils.TimeUtils * @author: Tony Shen * @date: 2024/5/2 21:40 * @version: V1.0 <描述当前版本功能> */ private const val yyyy_MM_dd_HH_mm_ss_SSS = "yyyy-MM-dd-H-mm-ss-SSS" private val formatterWithHorizontal by lazy { DateTimeFormatter.ofPattern(yyyy_MM_dd_HH_mm_ss_SSS).withZone(ZoneId.systemDefault()) } val formatTimestamp by lazy { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) } /** * 生成图片的名称 */ fun currentTime(): String = ZonedDateTime.now().format(formatterWithHorizontal) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/Typealiases.kt ================================================ package cn.netdiscovery.monica.utils import cn.netdiscovery.monica.ui.controlpanel.cropimage.setting.CropProperties import java.awt.image.BufferedImage /** * * @FileName: * cn.netdiscovery.monica.utils.Typealiases * @author: Tony Shen * @date: 2024/9/21 14:56 * @version: V1.0 <描述当前版本功能> */ typealias CVAction = (byteArray:ByteArray) -> IntArray typealias CVSuccess = (image: BufferedImage)->Unit typealias CVFailure = (e:Exception) -> Unit typealias OnCropPropertiesChange = (cropProperties: CropProperties) -> Unit typealias Action = () -> Unit ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/Validation.kt ================================================ package cn.netdiscovery.monica.utils /** * * @FileName: * cn.netdiscovery.monica.utils.Validation * @author: Tony Shen * @date: 2024/10/25 10:24 * @version: V1.0 <描述当前版本功能> */ /** * 对字段进行转换和验证 * @param block 对字段进行转换 * @param failed 对字段转换失败的回调 */ fun getValidateField(block:()-> T, failed:()->Unit): T? { return try { block.invoke() } catch (e:Exception) { failed.invoke() null } } /** * 对字段进行转换和验证 * @param block 对字段进行转换 * @param condition 对字段的值进行校验 * @param failed 对字段转换失败/校验失败的回调 */ fun getValidateField(block:()-> T, condition: (T) -> Boolean, failed:()->Unit): T? { return try { val field = block.invoke() if (condition.invoke(field)) { field } else { failed.invoke() null } } catch (e:Exception) { failed.invoke() null } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/WebScreenshotUtils.kt ================================================ package cn.netdiscovery.monica.utils import cn.netdiscovery.monica.config.arch import cn.netdiscovery.monica.config.isLinux import cn.netdiscovery.monica.config.isMac import cn.netdiscovery.monica.config.isWindows import cn.netdiscovery.monica.exception.ErrorSeverity import cn.netdiscovery.monica.exception.ErrorType import cn.netdiscovery.monica.exception.showError import cn.netdiscovery.monica.state.ApplicationState import cn.netdiscovery.monica.utils.extensions.launchWithSuspendLoading import com.google.gson.Gson import com.google.gson.JsonObject import com.google.gson.JsonParser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.slf4j.Logger import org.slf4j.LoggerFactory import java.awt.image.BufferedImage import java.io.File import java.io.FileOutputStream import java.io.FileNotFoundException import java.io.IOException import java.nio.file.Files import java.nio.file.StandardCopyOption import java.util.concurrent.TimeUnit import java.util.zip.ZipInputStream import javax.imageio.ImageIO import kotlin.concurrent.thread /** * 网页截图工具类 * * @author: Tony Shen * @date: 2026/01/12 * @version: V1.0 */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) private data class NodeRuntime( val executable: String, val bundled: Boolean ) private data class BundledWebRuntime( val runtimeRoot: File, val scriptFile: File, val nodeFile: File?, val browsersDir: File? ) /** * Cookie 数据类 */ data class Cookie( val name: String, val value: String, val domain: String? = null, val path: String? = null, val expires: Long? = null, val httpOnly: Boolean = false, val secure: Boolean = false, val sameSite: String? = null // "Strict", "Lax", "None" ) /** * 网页截图配置 */ data class WebScreenshotOptions( val fullPage: Boolean = true, val waitUntil: String = "networkidle", // load, domcontentloaded, networkidle val timeout: Long = 30000, // 30秒 val viewportWidth: Int? = null, val viewportHeight: Int? = null, val deviceScaleFactor: Double = 2.0, val cookies: List = emptyList() // Cookie 列表 ) /** * 检查 Node.js 是否已安装 */ private fun checkNodeInstalled(runtime: NodeRuntime): Boolean { return try { val process = ProcessBuilder(runtime.executable, "--version") .redirectErrorStream(true) .start() val finished = process.waitFor(3, TimeUnit.SECONDS) val result = finished && process.exitValue() == 0 process.destroy() result } catch (e: Exception) { logger.debug("Node.js 检查失败: ${runtime.executable}", e) false } } fun checkNodeInstalled(): Boolean = checkNodeInstalled(resolveNodeRuntime()) /** * 获取当前平台的资源目录名 */ private fun getCurrentPlatformResourceDirName(): String? { val normalizedArch = arch.lowercase() return when { isMac && (normalizedArch == "aarch64" || normalizedArch == "arm64") -> "macos-arm64" isMac -> "macos-x64" isWindows -> "windows" isLinux && (normalizedArch == "aarch64" || normalizedArch == "arm64") -> "linux-arm64" isLinux -> "linux-x64" else -> null } } /** * 获取网页截图资源搜索目录 */ private fun getWebScreenshotResourceDirs(): List { val resourceDirs = buildList { val composeResourcesDir = System.getProperty("compose.application.resources.dir") ?.takeIf { it.isNotBlank() } ?.let { File(it) } composeResourcesDir?.let { add(it) add(File(it, "common")) } val projectResourcesDir = File("resources") add(projectResourcesDir) add(File(projectResourcesDir, "common")) } return resourceDirs .map { it.absoluteFile } .distinctBy { it.path } } /** * 在资源目录中查找文件 */ private fun findResourceFile(relativePath: String): File? { getWebScreenshotResourceDirs().forEach { dir -> val file = File(dir, relativePath) if (file.exists()) { return file } } return null } private fun getUserWebRuntimeBaseDir(): File { val userHome = File(System.getProperty("user.home")) return when { isMac -> File(userHome, "Library/Application Support/Monica/web-screenshot-runtime") isWindows -> { val appData = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } val baseDir = appData?.let(::File) ?: File(userHome, "AppData/Roaming") File(baseDir, "Monica/web-screenshot-runtime") } else -> { val xdgDataHome = System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() } val baseDir = xdgDataHome?.let(::File) ?: File(userHome, ".local/share") File(baseDir, "Monica/web-screenshot-runtime") } } } private fun getUserWebRuntimeRoot(): File? { val platformDir = getCurrentPlatformResourceDirName() ?: return null return File(getUserWebRuntimeBaseDir(), platformDir) } private fun getOfflineRuntimePayloadZip(): File? { val platformDir = getCurrentPlatformResourceDirName() ?: return null return findResourceFile("web-screenshot-runtime/$platformDir/runtime.zip") } private fun getBundledNodeExecutable(runtimeRoot: File): File? { val candidates = if (isWindows) { listOf( File(runtimeRoot, "node/node.exe"), File(runtimeRoot, "node.exe") ) } else { listOf( File(runtimeRoot, "node/bin/node"), File(runtimeRoot, "node/node"), File(runtimeRoot, "bin/node") ) } candidates.forEach { nodeFile -> if (nodeFile.exists()) { if (!isWindows && !nodeFile.canExecute()) { nodeFile.setExecutable(true) } return nodeFile } } return null } private fun getBundledPlaywrightBrowsersPath(runtimeRoot: File): File? { val candidates = listOf( File(runtimeRoot, "node_modules/playwright-core/.local-browsers"), File(runtimeRoot, "ms-playwright") ) candidates.forEach { browsersDir -> if (browsersDir.exists()) { return browsersDir } } return null } private fun isRuntimeReady(runtimeRoot: File): Boolean { val script = File(runtimeRoot, "web-screenshot.js") val packageJson = File(runtimeRoot, "package.json") val playwrightModule = File(runtimeRoot, "node_modules/playwright/package.json") return script.exists() && packageJson.exists() && playwrightModule.exists() } private fun computeRuntimePayloadStamp(payloadZip: File): String = "${payloadZip.length()}:${payloadZip.lastModified()}" private fun restoreRuntimeExecutablePermissions(runtimeRoot: File) { if (isWindows) return runtimeRoot.walkTopDown() .filter { it.isFile } .forEach { file -> val relativePath = file.relativeTo(runtimeRoot).invariantSeparatorsPath val fileName = file.name val shouldBeExecutable = relativePath == "node/bin/node" || relativePath.endsWith("/headless_shell") || relativePath.endsWith("/chrome-headless-shell") || relativePath.endsWith("/Chromium") || relativePath.endsWith("/chrome") || fileName.startsWith("ffmpeg") if (shouldBeExecutable && !file.canExecute()) { file.setExecutable(true) } } } private fun extractOfflineRuntime(payloadZip: File, runtimeRoot: File) { val tempRoot = File(runtimeRoot.parentFile, "${runtimeRoot.name}.tmp-${System.currentTimeMillis()}") if (tempRoot.exists()) { tempRoot.deleteRecursively() } tempRoot.mkdirs() ZipInputStream(payloadZip.inputStream().buffered()).use { zipInput -> while (true) { val entry = zipInput.nextEntry ?: break val outputFile = File(tempRoot, entry.name) if (entry.isDirectory) { outputFile.mkdirs() } else { outputFile.parentFile?.mkdirs() FileOutputStream(outputFile).use { output -> zipInput.copyTo(output) } } zipInput.closeEntry() } } restoreRuntimeExecutablePermissions(tempRoot) File(tempRoot, ".payload-stamp").writeText(computeRuntimePayloadStamp(payloadZip)) runtimeRoot.parentFile?.mkdirs() if (runtimeRoot.exists()) { runtimeRoot.deleteRecursively() } Files.move(tempRoot.toPath(), runtimeRoot.toPath(), StandardCopyOption.REPLACE_EXISTING) } private fun getLegacyBundledRuntime(): BundledWebRuntime? { val resourceRoot = getWebScreenshotResourceDirs().firstOrNull { dir -> File(dir, "web-screenshot.js").exists() } ?: return null return BundledWebRuntime( runtimeRoot = resourceRoot, scriptFile = File(resourceRoot, "web-screenshot.js"), nodeFile = getBundledNodeExecutable(resourceRoot), browsersDir = getBundledPlaywrightBrowsersPath(resourceRoot) ) } private fun ensureBundledWebRuntime(): BundledWebRuntime? { val payloadZip = getOfflineRuntimePayloadZip() ?: return getLegacyBundledRuntime() val runtimeRoot = getUserWebRuntimeRoot() ?: return getLegacyBundledRuntime() val expectedStamp = computeRuntimePayloadStamp(payloadZip) val stampFile = File(runtimeRoot, ".payload-stamp") val needsExtract = !isRuntimeReady(runtimeRoot) || !stampFile.exists() || stampFile.readText() != expectedStamp if (needsExtract) { logger.info("解压离线网页截图运行时到: ${runtimeRoot.absolutePath}") extractOfflineRuntime(payloadZip, runtimeRoot) } restoreRuntimeExecutablePermissions(runtimeRoot) return BundledWebRuntime( runtimeRoot = runtimeRoot, scriptFile = File(runtimeRoot, "web-screenshot.js"), nodeFile = getBundledNodeExecutable(runtimeRoot), browsersDir = getBundledPlaywrightBrowsersPath(runtimeRoot) ) } private fun resolveNodeRuntime(): NodeRuntime { val bundledNode = ensureBundledWebRuntime()?.nodeFile return if (bundledNode != null) { logger.info("使用内置 Node.js: ${bundledNode.absolutePath}") NodeRuntime(bundledNode.absolutePath, bundled = true) } else { NodeRuntime("node", bundled = false) } } /** * 从剪贴板获取 URL */ fun getUrlFromClipboard(): String? { return try { val clipboard = java.awt.Toolkit.getDefaultToolkit().systemClipboard val contents = clipboard.getContents(null) if (contents != null && contents.isDataFlavorSupported(java.awt.datatransfer.DataFlavor.stringFlavor)) { val text = contents.getTransferData(java.awt.datatransfer.DataFlavor.stringFlavor) as String val trimmed = text.trim() // 检查是否是有效的 URL if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { logger.info("从剪贴板获取到 URL: $trimmed") trimmed } else { logger.debug("剪贴板内容不是有效的 URL: $trimmed") null } } else { logger.debug("剪贴板中没有文本内容") null } } catch (e: Exception) { logger.error("读取剪贴板失败", e) null } } /** * 从剪贴板获取 Cookie * 支持两种格式: * 1. Netscape Cookie 格式(从浏览器扩展如 EditThisCookie 导出) * 2. JSON 格式(Playwright Cookie 格式) */ fun getCookiesFromClipboard(): List { return try { val clipboard = java.awt.Toolkit.getDefaultToolkit().systemClipboard val contents = clipboard.getContents(null) if (contents != null && contents.isDataFlavorSupported(java.awt.datatransfer.DataFlavor.stringFlavor)) { val text = contents.getTransferData(java.awt.datatransfer.DataFlavor.stringFlavor) as String val trimmed = text.trim() // 尝试解析 JSON 格式(Playwright Cookie 格式) if (trimmed.startsWith("[") || trimmed.startsWith("{")) { try { val jsonArray = JsonParser.parseString(trimmed) if (jsonArray.isJsonArray) { val cookies = mutableListOf() jsonArray.asJsonArray.forEach { element -> val obj = element.asJsonObject cookies.add( Cookie( name = obj.get("name")?.asString ?: "", value = obj.get("value")?.asString ?: "", domain = obj.get("domain")?.asString, path = obj.get("path")?.asString, expires = obj.get("expires")?.asLong, httpOnly = obj.get("httpOnly")?.asBoolean ?: false, secure = obj.get("secure")?.asBoolean ?: false, sameSite = obj.get("sameSite")?.asString ) ) } logger.info("从剪贴板解析到 ${cookies.size} 个 Cookie(JSON 格式)") return cookies } } catch (e: Exception) { logger.debug("解析 JSON Cookie 失败,尝试 Netscape 格式", e) } } // 尝试解析 Netscape Cookie 格式 val cookies = parseNetscapeCookies(trimmed) if (cookies.isNotEmpty()) { logger.info("从剪贴板解析到 ${cookies.size} 个 Cookie(Netscape 格式)") return cookies } emptyList() } else { emptyList() } } catch (e: Exception) { logger.error("读取剪贴板 Cookie 失败", e) emptyList() } } /** * 解析 Netscape Cookie 格式 * 格式:domain flag path secure expiration name value */ private fun parseNetscapeCookies(text: String): List { val cookies = mutableListOf() val lines = text.lines() // 跳过注释行和标题行 val dataLines = lines.filter { val trimmed = it.trim() !trimmed.startsWith("#") && trimmed.isNotEmpty() && (trimmed.contains("\t") && trimmed.split("\t").size >= 7) } dataLines.forEach { line -> try { val parts = line.split("\t") if (parts.size >= 7) { val domain = parts[0].trim() val path = parts[2].trim() val secure = parts[3].trim() == "TRUE" val expiration = parts[4].trim().toLongOrNull() val name = parts[5].trim() val value = parts[6].trim() if (name.isNotEmpty() && value.isNotEmpty()) { cookies.add( Cookie( name = name, value = value, domain = domain.takeIf { it.isNotEmpty() }, path = path.takeIf { it.isNotEmpty() }, expires = expiration, secure = secure ) ) } } } catch (e: Exception) { logger.debug("解析 Cookie 行失败: $line", e) } } return cookies } /** * 捕获网页长截图 * * @param url 网页URL * @param options 截图选项 * @return 截图结果,失败返回 Result.failure */ suspend fun captureWebPage( url: String, options: WebScreenshotOptions = WebScreenshotOptions() ): Result = withContext(Dispatchers.IO) { try { val bundledRuntime = ensureBundledWebRuntime() // 1. 检查 Node.js 环境 val nodeRuntime = resolveNodeRuntime() if (!checkNodeInstalled(nodeRuntime)) { return@withContext Result.failure( IllegalStateException( if (nodeRuntime.bundled) { "内置 Node.js 不可用,请检查安装包中的运行时资源" } else { "未检测到 Node.js 环境,请先安装 Node.js" } ) ) } // 2. 检查脚本文件是否存在 val scriptFile = bundledRuntime?.scriptFile if (scriptFile == null || !scriptFile.exists()) { val errorPath = scriptFile?.absolutePath ?: "未知路径" return@withContext Result.failure( FileNotFoundException("网页截图脚本不存在: $errorPath。请确保 web-screenshot.js 文件在 resources 目录下。") ) } // 3. 创建临时文件用于输出图片 val tempImageFile = File.createTempFile("web-screenshot-", ".png") tempImageFile.deleteOnExit() // 4. 检查并安装依赖 val workingDir = bundledRuntime?.runtimeRoot ?: scriptFile.parentFile ?: File(".") val packageJson = File(workingDir, "package.json") val nodeModules = File(workingDir, "node_modules") // 离线运行时应已自带依赖,这里只做完整性兜底检查 if (packageJson.exists() && !nodeModules.exists()) { logger.warn("检测到离线网页截图运行时不完整: ${workingDir.absolutePath}") return@withContext Result.failure( IllegalStateException("离线网页截图运行时不完整,请重新安装应用或重新打包") ) } // 5. 如果有 Cookie,保存到临时文件 val cookiesFile = if (options.cookies.isNotEmpty()) { val tempCookiesFile = File.createTempFile("web-screenshot-cookies-", ".json") tempCookiesFile.deleteOnExit() // 转换为 Playwright Cookie 格式 val gson = Gson() val cookieList = options.cookies.map { cookie -> val cookieObj = JsonObject() cookieObj.addProperty("name", cookie.name) cookieObj.addProperty("value", cookie.value) cookie.domain?.let { cookieObj.addProperty("domain", it) } cookie.path?.let { cookieObj.addProperty("path", it) } cookie.expires?.let { cookieObj.addProperty("expires", it) } cookieObj.addProperty("httpOnly", cookie.httpOnly) cookieObj.addProperty("secure", cookie.secure) cookie.sameSite?.let { cookieObj.addProperty("sameSite", it) } cookieObj } tempCookiesFile.writeText(gson.toJson(cookieList)) logger.info("已保存 ${options.cookies.size} 个 Cookie 到临时文件: ${tempCookiesFile.absolutePath}") tempCookiesFile } else { null } // 6. 构建命令 val command = mutableListOf().apply { add(nodeRuntime.executable) add(scriptFile.absolutePath) add(url) add(tempImageFile.absolutePath) add("--fullPage=${options.fullPage}") add("--waitUntil=${options.waitUntil}") add("--timeout=${options.timeout}") options.viewportWidth?.let { add("--viewportWidth=$it") } options.viewportHeight?.let { add("--viewportHeight=$it") } add("--deviceScaleFactor=${options.deviceScaleFactor}") cookiesFile?.let { add("--cookiesFile=${it.absolutePath}") } } logger.info("执行网页截图命令: ${command.joinToString(" ")}") // 7. 执行进程 val processBuilder = ProcessBuilder(command) .directory(workingDir) .redirectErrorStream(true) bundledRuntime?.browsersDir?.let { browsersDir -> processBuilder.environment()["PLAYWRIGHT_BROWSERS_PATH"] = browsersDir.absolutePath processBuilder.environment()["PLAYWRIGHT_SKIP_BROWSER_GC"] = "1" } val process = processBuilder.start() // 8. 并发消费输出,避免 stdout 管道写满导致子进程阻塞 val output = StringBuffer() val outputReaderThread = thread(start = true, isDaemon = true, name = "web-screenshot-output-reader") { try { process.inputStream.bufferedReader().use { reader -> reader.lineSequence().forEach { line -> output.appendLine(line) logger.debug("Node输出: $line") } } } catch (e: IOException) { logger.debug("读取网页截图进程输出结束", e) } } // 9. 等待进程完成(带超时) val processTimeout = options.timeout + 10000 // 额外10秒缓冲 val finished = process.waitFor(processTimeout, TimeUnit.MILLISECONDS) if (!finished) { process.destroyForcibly() process.inputStream.close() outputReaderThread.join(2000) tempImageFile.delete() cookiesFile?.delete() return@withContext Result.failure( java.util.concurrent.TimeoutException("网页截图超时(超过 ${processTimeout}ms)") ) } outputReaderThread.join(2000) val exitCode = process.exitValue() if (exitCode != 0) { logger.error("网页截图失败,退出码: $exitCode,输出: $output") tempImageFile.delete() cookiesFile?.delete() return@withContext Result.failure( RuntimeException("网页截图失败: $output") ) } // 10. 清理 Cookie 临时文件 cookiesFile?.delete() // 11. 读取图片文件 if (!tempImageFile.exists() || tempImageFile.length() == 0L) { cookiesFile?.delete() return@withContext Result.failure( IOException("截图文件未生成或为空") ) } val image = ImageIO.read(tempImageFile) if (image == null) { tempImageFile.delete() cookiesFile?.delete() return@withContext Result.failure( IOException("无法读取截图文件") ) } // 12. 清理临时文件 tempImageFile.delete() logger.info("网页截图成功,尺寸: ${image.width}x${image.height}") Result.success(image) } catch (e: Exception) { logger.error("网页截图异常", e) Result.failure(e) } } /** * 将网页截图加载到 ApplicationState */ fun loadWebScreenshotToState( state: ApplicationState, url: String, options: WebScreenshotOptions = WebScreenshotOptions() ) { state.scope.launchWithSuspendLoading { val result = captureWebPage(url, options) result.onSuccess { image -> try { logger.info("加载网页截图到应用状态") val currentImage = state.currentImage ?: state.rawImage if (currentImage != null) { state.addQueue(currentImage) } state.clearImage() state.rawImage = image state.currentImage = image state.rawImageFile = null state.rawImageFormat = null } catch (e: Exception) { logger.error("加载网页截图失败", e) showError( ErrorType.FILE_IO_ERROR, ErrorSeverity.MEDIUM, "加载截图失败", "加载截图失败: ${e.message}" ) } }.onFailure { error -> logger.error("网页截图失败", error) val userMessage: String = when (error) { is IllegalStateException -> { if (error.message?.contains("Playwright 依赖未安装") == true) { error.message ?: "Playwright 依赖未安装" } else { error.message ?: "未检测到 Node.js 环境" } } is java.util.concurrent.TimeoutException -> "截图超时,请检查网络连接或稍后重试" is FileNotFoundException -> "网页截图脚本不存在,请检查安装" is RuntimeException -> { val errorMsg = error.message ?: "" if (errorMsg.contains("Cannot find module 'playwright'")) { "Playwright 运行时缺失,请重新安装应用或重新打包离线运行时" } else if (errorMsg.contains("加载 Cookie 失败")) { "Cookie 加载失败,请检查 Cookie 格式、域名和有效期" } else { "网页截图失败: $errorMsg" } } else -> { val errorMsg = error.message ?: "" if (errorMsg.contains("Cannot find module 'playwright'")) { "Playwright 运行时缺失,请重新安装应用或重新打包离线运行时" } else if (errorMsg.contains("加载 Cookie 失败")) { "Cookie 加载失败,请检查 Cookie 格式、域名和有效期" } else { "网页截图失败: $errorMsg" } } } showError( ErrorType.NETWORK_ERROR, ErrorSeverity.MEDIUM, "网页截图失败", userMessage ) } } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/extensions/Any+Extensions.kt ================================================ package cn.netdiscovery.monica.utils.extensions import org.slf4j.Logger import org.slf4j.LoggerFactory import kotlin.reflect.full.primaryConstructor import kotlin.reflect.full.memberProperties import kotlin.reflect.jvm.isAccessible /** * * @FileName: * cn.netdiscovery.monica.utils.extensions.`Any+Extensions` * @author: Tony Shen * @date: 2025/3/7 14:24 * @version: V1.0 <描述当前版本功能> */ private val logger: Logger = LoggerFactory.getLogger(object : Any() {}.javaClass.enclosingClass) /** * 在构造函数中,打印所有参数的名称、参数值,便于调试 */ fun Any.printConstructorParamsWithValues() { val kClass = this::class val constructor = kClass.primaryConstructor if (constructor != null) { val paramValues = constructor.parameters.associateWith { param -> val paramName = param.name ?: "unknown" val property = kClass.memberProperties.find { it.name == paramName } property?.let { it.isAccessible = true // 允许访问 private 属性 it.getter.call(this) } } val params = paramValues.map { (param, value) -> "${param.name} = $value"}.joinToString { it } logger.info("${kClass.simpleName} parameters: $params") } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/extensions/Coroutine+Extensions.kt ================================================ package cn.netdiscovery.monica.utils.extensions import cn.netdiscovery.monica.utils.loadingDisplay import cn.netdiscovery.monica.utils.loadingDisplayWithSuspend import com.safframework.kotlin.coroutines.IO import kotlinx.coroutines.* /** * * @FileName: * cn.netdiscovery.monica.utils.extensions.`Coroutine+Extensions` * @author: Tony Shen * @date: 2024/8/28 18:28 * @version: V1.0 <描述当前版本功能> */ fun CoroutineScope.launchWithLoading(block:()->Unit) { this.launch(IO) { loadingDisplay(block) } } fun CoroutineScope.launchWithSuspendLoading(block:suspend ()->Unit): Job { return this.launch(IO) { loadingDisplayWithSuspend(block) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/extensions/DrawScope+Extensions.kt ================================================ package cn.netdiscovery.monica.utils.extensions import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.nativeCanvas /** * * @FileName: * cn.netdiscovery.monica.utils.extensions.`DrawScope+Extensions` * @author: Tony Shen * @date: 2024/11/25 00:49 * @version: V1.0 <描述当前版本功能> */ fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) { with(drawContext.canvas.nativeCanvas) { val checkPoint = saveLayer(null, null) block() restoreToCount(checkPoint) } } ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/extensions/Number+Extensions.kt ================================================ package cn.netdiscovery.monica.utils.extensions import java.text.DecimalFormat /** * * @FileName: * cn.netdiscovery.monica.utils.extensions.`Number+Extension` * @author: Tony Shen * @date: 2024/5/4 14:30 * @version: V1.0 <描述当前版本功能> */ val format by lazy { DecimalFormat("#.##") } fun Float.to2fStr(): String = format.format(this) ================================================ FILE: src/jvmMain/kotlin/cn/netdiscovery/monica/utils/extensions/String+Extensions.kt ================================================ package cn.netdiscovery.monica.utils.extensions /** * * @FileName: * cn.netdiscovery.monica.utils.extensions.`String+Extension` * @author: Tony Shen * @date: 2024/5/2 15:30 * @version: V1.0 <描述当前版本功能> */ import java.net.URL /** * 将 string 字符串安全地转换成 int 类型 */ fun String.safelyConvertToInt(): Int? = this.toDoubleOrNull()?.takeIf { it % 1 == 0.0 }?.toInt() fun String.isValidUrl(): Boolean { return try { URL(this).toURI() true } catch (e: Exception) { false } } ================================================ FILE: src/jvmMain/resources/logback.xml ================================================ ${pattern} ${LOG_HOME}monica.log ${LOG_HOME}monica_%d{yyyy-MM-dd}.log 30 ${pattern} ================================================ FILE: src/jvmTest/kotlin/cn/netdiscovery/monica/editor/layer/ExportManagerTest.kt ================================================ package cn.netdiscovery.monica.editor.layer import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.toAwtImage import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.Density import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.EditorController import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.ImageLayer import kotlin.test.Test import kotlin.test.assertEquals class EditorControllerExportTest { @Test fun `exportImageBitmap composes image layers`() { val editorController = EditorController() val redBitmap = createSolidBitmap(Color.Red, 8, 8) val imageLayer = ImageLayer(name = "背景图层", image = redBitmap) editorController.addLayer(imageLayer) val result = editorController.exportImageBitmap( width = 8, height = 8, density = Density(1f) ) val buffered = result.toAwtImage() val pixel = buffered.getRGB(4, 4) assertEquals(Color.Red.toArgb(), pixel) } private fun createSolidBitmap(color: Color, width: Int, height: Int): ImageBitmap { val bitmap = ImageBitmap(width, height) val canvas = Canvas(bitmap) val paint = Paint().apply { this.color = color } canvas.drawRect(Rect(0f, 0f, width.toFloat(), height.toFloat()), paint) return bitmap } } ================================================ FILE: src/jvmTest/kotlin/cn/netdiscovery/monica/editor/layer/LayerManagerTest.kt ================================================ package cn.netdiscovery.monica.editor.layer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.ImageLayer import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.LayerManager import cn.netdiscovery.monica.ui.controlpanel.shapedrawing.layer.ShapeLayer import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue class LayerManagerTest { @Test fun `add layer updates active layer`() { val manager = LayerManager() val layer = ImageLayer(name = "Background", image = null) manager.addLayer(layer) assertEquals(1, manager.layers.value.size) assertEquals(layer.id, manager.activeLayer.value?.id) } @Test fun `remove active layer selects previous`() { val manager = LayerManager() val first = ImageLayer(name = "Layer 1", image = null) val second = ShapeLayer(name = "Layer 2") manager.addLayer(first) manager.addLayer(second) assertEquals(second.id, manager.activeLayer.value?.id) manager.removeLayer(second.id) assertEquals(1, manager.layers.value.size) assertEquals(first.id, manager.activeLayer.value?.id) } @Test fun `move layer up swaps ordering`() { val manager = LayerManager() val bottom = ShapeLayer("Bottom") val top = ShapeLayer("Top") manager.addLayer(bottom) manager.addLayer(top) assertEquals(listOf(bottom, top), manager.layers.value) manager.moveLayerUp(bottom.id) assertEquals(listOf(top, bottom), manager.layers.value) } @Test fun `clear removes all layers`() { val manager = LayerManager() manager.addLayer(ImageLayer("A", null)) manager.addLayer(ImageLayer("B", null)) manager.clear() assertTrue(manager.layers.value.isEmpty()) assertNull(manager.activeLayer.value) } }