diff --git a/docs/content/recipes/clean/index.md b/docs/content/recipes/clean/index.md new file mode 100644 index 0000000..0e0583a --- /dev/null +++ b/docs/content/recipes/clean/index.md @@ -0,0 +1,9 @@ +--- +title: "Recipe: clean" +summary: "Cleans a mesh of common issues" +weight: 110 +--- + +The `clean` recipe fixes some common issues with unnecessary geometry in a mesh by removing unreferenced vertices, zero area faces, duplicate vertices, and duplicate faces. + +It also has options for removing extraneous geometry components (often appearing as floating triangle clusters or unneeded reconstructions in photogrammetry results) by deleting everything but the largest component or, when doing turntable capture, deleting everything but the component central to the capture volume. \ No newline at end of file diff --git a/docs/content/recipes/photogrammetry/index.md b/docs/content/recipes/photogrammetry/index.md new file mode 100644 index 0000000..a9155cb --- /dev/null +++ b/docs/content/recipes/photogrammetry/index.md @@ -0,0 +1,9 @@ +--- +title: "Recipe: photogrammetry" +summary: "Creates a mesh and texture from capture image set folders" +weight: 110 +--- + +The `photogrammetry` recipe takes zip files of capture image sets (including alignment-only and masking images) and aligns the images, generates a mesh, cleans the mesh of unnecessary geometry, and finally generates a texture mapped to the cleaned mesh. This full photogrammetry pipeline currently works with Agisoft Metashape, with limited support for the RealityCapture and Meshroom applications. + +Resulting meshes may require some manual cleanup or fixing dependent on the input and masking data available. \ No newline at end of file diff --git a/docs/content/recipes/si-zip-photogrammetry/index.md b/docs/content/recipes/si-zip-photogrammetry/index.md new file mode 100644 index 0000000..c9e4941 --- /dev/null +++ b/docs/content/recipes/si-zip-photogrammetry/index.md @@ -0,0 +1,9 @@ +--- +title: "Recipe: si-zip-photogrammetry" +summary: "Creates a mesh and texture from zipped capture image sets" +weight: 120 +--- + +The `si-zip-photogrammetry` recipe is similar to the `photogrammetry` recipe but with some steps specific to the Smithsonian workflow. It takes folders of capture image sets (including alignment-only and masking images) as input and aligns the images, generates a mesh, cleans the mesh of unnecessary geometry, and finally generates a texture mapped to the cleaned mesh. This full photogrammetry pipeline currently works with Agisoft Metashape, with limited support for the RealityCapture and Meshroom applications. + +Resulting meshes may require some manual cleanup or fixing dependent on the input and masking data available. \ No newline at end of file diff --git a/docs/content/tasks/batch-convert-image/index.md b/docs/content/tasks/batch-convert-image/index.md new file mode 100644 index 0000000..531f85e --- /dev/null +++ b/docs/content/tasks/batch-convert-image/index.md @@ -0,0 +1,23 @@ +--- +title: BatchConvertImage +summary: Converts folders of image files between different formats. +--- + + +### Description + +Converts image files between different formats. + +Optionally clip the images to black or white. + +Tool: [ImageMagick](../../tools/imageMagick) + +### Options + +| Option | Type | Required | Default | Description | +|-----------------|---------|----------|---------|---------------------------------------------------------------------------------------------------------------------| +| inputImageFolder| string | yes | | Input image folder name. | +| outputImageFolder | string | yes | | Output image folder name. | +| quality | number | no | 70 | Compression quality for JPEG images (0 - 100). | +| filetype | string | no | 'jpg' | File type to convert images to. | +| level | number | no | none | If provided, clips image to black (value < 128) or white (value > 128) | \ No newline at end of file diff --git a/docs/content/tasks/combine-mesh/index.md b/docs/content/tasks/combine-mesh/index.md new file mode 100644 index 0000000..21d103b --- /dev/null +++ b/docs/content/tasks/combine-mesh/index.md @@ -0,0 +1,20 @@ +--- +title: CombineMesh +summary: Combines two meshes into a single self contained .fbx +--- + +### Description + +Combines two meshes into a single self contained .fbx. + +Tool: [Blender](../../tools/blender) + +### Options + +| Option | Type | Required | Default | Description | +|---------------|----------|----------|--------------------|---------------------------------------------------------------| +| baseMeshFile | string | yes | | Base mesh file name. | +| inputMeshFile | string | yes | | Input mesh file name to combine with base. | +| inputMeshBasename | string | yes | | Name used for merged input mesh | +| outputMeshFile | string | yes | | Output mesh file name. | +| timeout | number | no | 0 | Maximum task execution time in seconds | \ No newline at end of file diff --git a/docs/content/tasks/merge-mesh/index.md b/docs/content/tasks/merge-mesh/index.md new file mode 100644 index 0000000..a5243fb --- /dev/null +++ b/docs/content/tasks/merge-mesh/index.md @@ -0,0 +1,19 @@ +--- +title: MergeMesh +summary: Merges a multi-mesh model file into one .obj and texture +--- + +### Description + +Merges a multi-mesh model file into one .obj and texture. + +Tool: [Blender](../../tools/blender) + +### Options + +| Option | Type | Required | Default | Description | +|---------------|----------|----------|--------------------|---------------------------------------------------------------| +| inputMeshFile | string | yes | | Input mesh file name to merge. | +| outputMeshFile | string | yes | | Output mesh file name. | +| outputTextureFile | string | yes | | Output texture file name. | +| timeout | number | no | 0 | Maximum task execution time in seconds | \ No newline at end of file diff --git a/docs/content/tasks/photogrammetry-tex/index.md b/docs/content/tasks/photogrammetry-tex/index.md new file mode 100644 index 0000000..0f222c2 --- /dev/null +++ b/docs/content/tasks/photogrammetry-tex/index.md @@ -0,0 +1,23 @@ +--- +title: PhotogrammetryTex +summary: Uses photogrammetry capture images to project a texture onto existing geometry. +--- + + +### Description + +Uses photogrammetry capture images to project a texture onto existing geometry using saved camera positions from a previous photogrammetry process. + +Tools: [Metashape](../../tools/metashape) (** Planned implementations for RealityCapture and Meshroom **) + +### Options + +| Option | Type | Required | Default | Description | +|----------------------|---------|----------|-----------|----------------------------------------------------------------------------------------------| +| inputImageFolder | string | yes | | Input image folder zip file. | +| inputModelFile | string | yes | | Metashape only: Alignment image folder. | +| outputFile | string | yes | | Base name used for output files. | +| camerasFile | string | yes | | Name used for saved camera position file. | +| scalebarFile | string | no | | CSV file with scalebar markers and distances. ([Example scalebar file](./scalebar-defs.csv)) | +| timeout | number | no | 0 | Maximum task execution time in seconds (default: 0, uses timeout defined in tool setup). | +| tool | string | no | "Metashape" | Tool to use for decimation: "Metashape", "RealityCapture", or "Meshroom". | \ No newline at end of file diff --git a/docs/content/tasks/photogrammetry/index.md b/docs/content/tasks/photogrammetry/index.md new file mode 100644 index 0000000..5f6e6b6 --- /dev/null +++ b/docs/content/tasks/photogrammetry/index.md @@ -0,0 +1,36 @@ +--- +title: Photogrammetry +summary: Generates a mesh and texture from zipped image sets using photogrammetry techniques. +--- + + +### Description + +Generates a mesh and texture from zipped image sets using photogrammetry techniques. It includes options for masking image sets and alignment-only images. + +Tools: [Metashape](../../tools/metashape), +With limited support by: [RealityCapture](../../tools/reality-capture), [Meshroom](../../tools/meshroom) + +### Options + +| Option | Type | Required | Default | Description | +|----------------------|---------|----------|-----------|----------------------------------------------------------------------------------------------| +| inputImageFolder | string | yes | | Input image folder zip file. | +| alignImageFolder | string | yes | | Metashape only: Alignment image folder. | +| maskImageFolder | string | no | | Metashape only: Mask image folder. | +| outputFile | string | no | | Base name used for output files. | +| camerasFile | string | no | | Metashape only: Name used for saved camera position file. | +| scalebarFile | string | no | | CSV file with scalebar markers and distances. ([Example scalebar file](./scalebar-defs.csv)) | +| optimizeMarkers | boolean | no | false | Metashape only: Flag to enable discarding high-error markers. | +| alignmentLimit | number | no | 50 | Metashape only: Percent success required to pass alignment stage. | +| tiepointLimit | integer | no | 25000 | Metashape only: Max number of tiepoints. | +| keypointLimit | integer | no | 75000 | Metashape only: Max number of keypoints. | +| turntableGroups | boolean | no | false | Metashape only: Flag to process images as SI-formatted turntable groups. | +| depthMaxNeighbors | integer | no | 16 | Metashape only: Max neighbors value to use for depth map generation. | +| genericPreselection | boolean | no | true | Metashape only: Flag = true to use generic preselection. | +| meshQuality | string | no | "High" | Metashape only: Preset for mesh quality ("Low", "Medium", "High", "Highest", "Custom"). | +| customFaceCount | integer | no | 3000000 | Metashape only: If meshQuality is custom, this defines the goal face count. | +| depthMapQuality | string | no | "Highest" | Metashape only: Preset for depth map quality ("Low", "Medium", "High", "Highest"). | +| maskMode | string | no | "File" | Metashape only: Desired masking operation. "File" assumes provided image is the mask, "Background" uses the background of the image as a basis for 'smart' masking. | +| timeout | number | no | 0 | Maximum task execution time in seconds (default: 0, uses timeout defined in tool setup). | +| tool | string | no | "Metashape" | Tool to use for decimation: "Metashape", "RealityCapture", or "Meshroom". | \ No newline at end of file diff --git a/docs/content/tasks/photogrammetry/scalebar-defs.csv b/docs/content/tasks/photogrammetry/scalebar-defs.csv new file mode 100644 index 0000000..b43c10e --- /dev/null +++ b/docs/content/tasks/photogrammetry/scalebar-defs.csv @@ -0,0 +1,33 @@ +marker1,marker2,distance +4,5,0.2498 +5,6,0.24987 +7,8,0.24985 +9,10,0.49965 +10,11,0.49963 +12,13,0.24976 +13,14,0.24987 +33,34,0.50016 +34,35,0.50034 +36,37,0.24997 +37,38,0.2501 +41,42,0.49999 +42,43,0.50019 +44,45,0.25008 +45,46,0.24996 +49,50,0.50009 +50,51,0.50007 +52,53,0.25 +53,54,0.25004 +55,56,0.2501 +57,58,0.50016 +58,59,0.50013 +63,64,0.25008 +65,66,0.50021 +66,67,0.50029 +68,69,0.25008 +69,70,0.25006 +73,74,0.50026 +74,75,0.50035 +76,77,0.25014 +77,78,0.25011 +79,80,0.25007 diff --git a/docs/content/tasks/screenshot/index.md b/docs/content/tasks/screenshot/index.md new file mode 100644 index 0000000..9ebb51d --- /dev/null +++ b/docs/content/tasks/screenshot/index.md @@ -0,0 +1,17 @@ +--- +title: Screenshot +summary: Generates a screenshot of the provided geometry +--- + +### Description + +Generates a screenshot of the provided geometry + +Tool: [Blender](../../tools/blender) + +### Options + +| Option | Type | Required | Default | Description | +|---------------|----------|----------|--------------------|---------------------------------------------------------------| +| inputMeshFile | string | yes | | Input mesh file name to combine with base. | +| timeout | number | no | 0 | Maximum task execution time in seconds | \ No newline at end of file diff --git a/docs/content/tools/meshroom/index.md b/docs/content/tools/meshroom/index.md new file mode 100644 index 0000000..f88e471 --- /dev/null +++ b/docs/content/tools/meshroom/index.md @@ -0,0 +1,27 @@ +--- +title: Meshroom +summary: Photogrammetry tool +--- + +### Information + +- Developer: AliceVision +- Website: https://alicevision.org/#meshroom +- License: https://github.com/alicevision/meshroom?tab=License-1-ov-file + +### Installation + +- Windows installer: https://www.fosshub.com/Meshroom.html?dwl=Meshroom-2023.3.0-win64.zip + +### Configuration + +Example configuration for Meshroom in the `tools.json` configuration file: + +```json +"RealityCapture": { + "executable": "C:\\Program Files\\Meshroom\\Meshroom-2021.1.0\\meshroom_batch.exe", + "version": "2021.1.0", + "maxInstances": 1, + "timeout": 0 // never +} +``` \ No newline at end of file diff --git a/docs/content/tools/metashape/index.md b/docs/content/tools/metashape/index.md new file mode 100644 index 0000000..960b847 --- /dev/null +++ b/docs/content/tools/metashape/index.md @@ -0,0 +1,27 @@ +--- +title: Agisoft Metashape +summary: Photogrammetry tool +--- + +### Information + +- Developer: Agisoft LLC +- Website: https://www.agisoft.com/ +- License: Commercial/Proprietary + +### Installation + +- Windows installer: https://www.agisoft.com/downloads/installer/ + +### Configuration + +Example configuration for Agisoft Metashape in the `tools.json` configuration file: + +```json +"RealityCapture": { + "executable": "C:\\Program Files\\Agisoft\\Metashape Pro\\metashape.exe", + "version": "v1.8.3, build 14331", + "maxInstances": 1, + "timeout": 7200 +} +``` \ No newline at end of file diff --git a/docs/content/tools/reality-capture/index.md b/docs/content/tools/reality-capture/index.md index f604ad9..abbb713 100644 --- a/docs/content/tools/reality-capture/index.md +++ b/docs/content/tools/reality-capture/index.md @@ -3,10 +3,6 @@ title: Reality Capture summary: Photogrammetry tool --- -### Note - -_Reality Capture support is planned for a future release of Cook._ - ### Information - Developer: Capturing Reality s.r.o. @@ -24,7 +20,7 @@ Example configuration for Reality Capture in the `tools.json` configuration file ```json "RealityCapture": { "executable": "C:\\Program Files\\Capturing Reality\\RealityCapture\\RealityCapture.exe", - "version": "1.0.3.4658", + "version": "1.2.0.17385", "maxInstances": 1, "timeout": 0 } diff --git a/server/recipes/clean.json b/server/recipes/clean.json index e61fed2..c691c2a 100644 --- a/server/recipes/clean.json +++ b/server/recipes/clean.json @@ -30,6 +30,10 @@ "keepLargestComponent": { "type": "boolean", "default": true + }, + "isTurntable": { + "type": "boolean", + "default": false } }, "required": [ @@ -68,6 +72,25 @@ "highPolyMeshFile": "sourceMeshFile" } }, + "success": "'inspect'", + "failure": "$failure" + }, + "inspect": { + "task": "InspectMesh", + "description": "Validate mesh and inspect topology", + "pre": { + "deliverables": { + "inspectionReport": "outputFileBaseName & '-inspection.json'" + } + }, + "parameters": { + "meshFile": "sourceMeshFile", + "reportFile": "deliverables.inspectionReport", + "tool": "'Blender'" + }, + "post": { + "sceneSize": "$result.inspection.scene.geometry.size" + }, "success": "'clean-mesh'", "failure": "$failure" }, @@ -83,6 +106,8 @@ "inputMeshFile": "sourceMeshFile", "outputMeshFile": "deliverables.cleanedMeshFile", "keepLargestComponent": "keepLargestComponent", + "isTurntable": "isTurntable", + "sceneSize": "sceneSize", "timeout": 1200 }, "success": "'delivery'", diff --git a/server/recipes/photogrammetry.json b/server/recipes/photogrammetry.json index 592c268..4ad7d40 100644 --- a/server/recipes/photogrammetry.json +++ b/server/recipes/photogrammetry.json @@ -31,20 +31,112 @@ "minLength": 1, "format": "file" }, - "generatePointCloud": { + "alignImageFolder": { + "type": "string", + "minLength": 1, + "format": "file" + }, + "maskImageFolder": { + "type": "string", + "minLength": 1, + "format": "file" + }, + "optimizeMarkers": { "type": "boolean", "default": false }, - "optimizeMarkers": { + "alignmentLimit": { + "type": "number", + "default": 50, + "minimum": 0, + "maximum": 100 + }, + "convertToJpg": { "type": "boolean", "default": false + }, + "tiepointLimit": { + "type": "number", + "default": 25000, + "minimum": 1000, + "maximum": 50000 + }, + "keypointLimit": { + "type": "number", + "default": 75000, + "minimum": 1000, + "maximum": 120000 + }, + "turntableGroups": { + "type": "boolean", + "default": false + }, + "findTurntableCenter": { + "type": "boolean", + "default": false + }, + "saveScreenshot": { + "type": "boolean", + "default": false + }, + "genericPreselection": { + "type": "boolean", + "default": true + }, + "depthMaxNeighbors": { + "type": "number", + "default": 16, + "minimum": 4, + "maximum": 256 + }, + "meshQuality": { + "type": "string", + "enum": [ + "Low", + "Medium", + "High", + "Highest", + "Custom" + ], + "default": "High" + }, + "customFaceCount": { + "type": "number", + "default": 3000000 + }, + "depthMapQuality": { + "type": "string", + "enum": [ + "Low", + "Medium", + "High", + "Highest" + ], + "default": "Highest" + }, + "levelClipAlign": { + "type": "boolean", + "default": false + }, + "clipLevel": { + "type": "number", + "default": 150 + }, + "maskMode": { + "type": "string", + "enum": [ + "File", + "Background" + ], + "default": "File" } }, "required": [ "sourceImageFolder" ], "advanced": [ - + "alignmentLimit", "convertToJpg", "tiepointLimit", "keypointLimit", "turntableGroups", "genericPreselection", "depthMaxNeighbors", + "meshQuality", "customFaceCount", "depthMapQuality", "saveScreenshot", "levelClipAlign", "clipLevel", "maskMode" ], "additionalProperties": false }, @@ -55,7 +147,10 @@ "description": "Enable logging services", "pre": { "outputFileBaseName": "$baseName($firstTrue(outputFileBaseName, sourceImageFolder))", - "baseMeshName": "$firstTrue(outputFileBaseName, sourceImageFolder)" + "baseMeshName": "$firstTrue(outputFileBaseName, sourceImageFolder)", + "sourceFolderBaseName": "$baseName(sourceImageFolder)", + "doAlign": "$exists(alignImageFolder)", + "doMask": "$exists(maskImageFolder)" }, "parameters": { "logToConsole": true, @@ -71,7 +166,9 @@ "method": "transportMethod", "path": "$firstTrue(pickupPath, $currentDir)", "files": { - "sourceImageFolder": "sourceImageFolder" + "sourceImageFolder": "sourceImageFolder", + "alignImageFolder": "alignImageFolder", + "maskImageFolder": "maskImageFolder" } }, "success": "'unzip'", @@ -80,15 +177,96 @@ "unzip": { "task": "Zip", "description": "Unzip image folder", + "parameters": { + "inputFile1": "sourceImageFolder", + "operation": "'unzip'" + }, + "success": "'unzip-align'", + "failure": "$failure" + }, + "unzip-align": { + "task": "Zip", + "skip": "$not(doAlign)", + "description": "Unzip alignment image folder", "pre": { - "deliverables": { - "objZipLow": "scaleToMeters ? baseMeshMapNameLow & '-obj_std.zip' : baseMeshMapNameLow & '-obj.zip'" - } + "alignFolderBaseName": "$baseName(alignImageFolder)" }, "parameters": { - "inputFile1": "sourceImageFolder", + "inputFile1": "alignImageFolder", + "operation": "'unzip'" + }, + "success": "'make-level-clip-folder'", + "failure": "$failure" + }, + "make-level-clip-folder": { + "task": "FileOperation", + "skip": "$not(levelClipAlign)", + "description": "Create folder for converted images", + "pre": { + "sourceFolderConverted": "'clipped_alignment'" + }, + "parameters": { + "operation": "'CreateFolder'", + "name": "sourceFolderConverted" + }, + "success": "'level-align'", + "failure": "$failure" + }, + "level-align": { + "task": "BatchConvertImage", + "skip": "$not(levelClipAlign)", + "description": "Generate level-clipped alignment images", + "parameters": { + "inputImageFolder": "sourceFolderBaseName", + "outputImageFolder": "sourceFolderConverted", + "filetype": "jpg", + "quality": "85", + "level": "clipLevel" + }, + "post": { + "alignFolderBaseName": "$baseName(sourceFolderConverted)" + }, + "success": "'unzip-mask'", + "failure": "$failure" + }, + "unzip-mask": { + "task": "Zip", + "skip": "$not(doMask)", + "description": "Unzip mask image folder", + "pre": { + "maskFolderBaseName": "$baseName(maskImageFolder)" + }, + "parameters": { + "inputFile1": "maskImageFolder", "operation": "'unzip'" }, + "success": "'make-convert-folder'", + "failure": "$failure" + }, + "make-convert-folder": { + "task": "FileOperation", + "skip": "$not(convertToJpg)", + "description": "Create folder for converted images", + "pre": { + "sourceFolderConverted": "sourceFolderBaseName & '_converted'" + }, + "parameters": { + "operation": "'CreateFolder'", + "name": "sourceFolderConverted" + }, + "success": "'convert-to-jpg'", + "failure": "$failure" + }, + "convert-to-jpg": { + "task": "BatchConvertImage", + "skip": "$not(convertToJpg)", + "description": "Convert images to .jpg", + "parameters": { + "inputImageFolder": "sourceFolderBaseName", + "outputImageFolder": "sourceFolderConverted", + "filetype": "jpg", + "quality": "85" + }, "success": "'photogrammetry'", "failure": "$failure" }, @@ -103,12 +281,23 @@ } }, "parameters": { - "inputImageFolder": "sourceImageFolder", + "inputImageFolder": "convertToJpg ? sourceFolderConverted : sourceFolderBaseName", + "alignImageFolder": "alignFolderBaseName", + "maskImageFolder": "maskFolderBaseName", "camerasFile": "camerasFile", "outputFile": "deliverables.meshFile", "scalebarFile": "scalebarCSV", - "generatePointCloud": "generatePointCloud", "optimizeMarkers": "optimizeMarkers", + "alignmentLimit": "alignmentLimit", + "tiepointLimit": "tiepointLimit", + "keypointLimit": "keypointLimit", + "turntableGroups": "turntableGroups", + "genericPreselection": "genericPreselection", + "depthMaxNeighbors": "depthMaxNeighbors", + "meshQuality": "meshQuality", + "customFaceCount": "customFaceCount", + "depthMapQuality": "depthMapQuality", + "maskMode": "maskMode", "tool": "tool", "timeout": 86400 }, @@ -124,6 +313,25 @@ "name": "'texturedMesh.obj'", "newName": "deliverables.meshFile" }, + "success": "'inspect'", + "failure": "$failure" + }, + "inspect": { + "task": "InspectMesh", + "description": "Validate mesh and inspect topology", + "pre": { + "deliverables": { + "inspectionReport": "outputFileBaseName & '-inspection.json'" + } + }, + "parameters": { + "meshFile": "deliverables.meshFile", + "reportFile": "deliverables.inspectionReport", + "tool": "'Blender'" + }, + "post": { + "sceneSize": "$result.inspection.scene.geometry.size" + }, "success": "'cleanup'", "failure": "$failure" }, @@ -138,6 +346,8 @@ "parameters": { "inputMeshFile": "deliverables.meshFile", "outputMeshFile": "deliverables.cleanedMeshFile", + "isTurntable": "findTurntableCenter", + "sceneSize": "sceneSize", "timeout": 1200 }, "success": "'texture'", @@ -154,7 +364,7 @@ } }, "parameters": { - "inputImageFolder": "sourceImageFolder", + "inputImageFolder": "convertToJpg ? sourceFolderConverted : sourceFolderBaseName", "inputModelFile": "deliverables.cleanedMeshFile", "camerasFile": "camerasFile", "outputFile": "deliverables.finalMeshFile", @@ -162,6 +372,17 @@ "tool": "tool", "timeout": 86400 }, + "success": "'screenshot'", + "failure": "$failure" + }, + "screenshot": { + "task": "Screenshot", + "skip": "$not(saveScreenshot)", + "description": "Generate screenshot of result geometry.", + "parameters": { + "inputMeshFile": "deliverables.finalMeshFile", + "timeout": 1200 + }, "success": "'delivery'", "failure": "$failure" }, diff --git a/server/recipes/si-path-zip.json b/server/recipes/si-path-zip.json new file mode 100644 index 0000000..75013c5 --- /dev/null +++ b/server/recipes/si-path-zip.json @@ -0,0 +1,79 @@ +{ + "id": "e965d8a9-6003-461a-bc92-c01aa67f9e94", + "name": "si-nas-zip", + "description": "Zips files directly from filesystem path", + "version": "1", + "start": "log", + + "parameterSchema": { + "type": "object", + "properties": { + "sourceFolderPath": { + "type": "string", + "minLength": 1 + }, + "outputFileBaseName": { + "type": "string", + "minLength": 1 + }, + "filetype": { + "type": "string", + "minLength": 1 + }, + "recursive": { + "type": "boolean", + "default": false + } + }, + "required": [ + "sourceFolderPath" + ], + "advanced": [ + ], + "additionalProperties": false + }, + + "steps": { + "log": { + "task": "Log", + "description": "Enable logging services", + "pre": { + }, + "parameters": { + "logToConsole": true, + "reportFile": "'zip-report.json'" + }, + "success": "'zip-files'", + "failure": "$failure" + }, + "zip-files": { + "task": "Zip", + "description": "Zip files direct from storage", + "pre": { + "deliverables": { + "fileZip": "$firstTrue(outputFileBaseName, 'zippedFiles.zip')" + } + }, + "parameters": { + "inputFile1": "sourceFolderPath", + "fileFilter": "filetype", + "recursive": "recursive", + "outputFile": "deliverables.fileZip", + "operation": "'path-zip'" + }, + "success": "'delivery'", + "failure": "$failure" + }, + "delivery": { + "task": "Delivery", + "description": "Send result files back to client", + "parameters": { + "method": "transportMethod", + "path": "$firstTrue(deliveryPath, pickupPath, $currentDir)", + "files": "deliverables" + }, + "success": "$success", + "failure": "$failure" + } + } +} diff --git a/server/recipes/si-zip-photogrammetry.json b/server/recipes/si-zip-photogrammetry.json new file mode 100644 index 0000000..952d173 --- /dev/null +++ b/server/recipes/si-zip-photogrammetry.json @@ -0,0 +1,512 @@ +{ + "id": "7310026c-68cb-4470-9841-f026e3bd9069", + "name": "si-zip-photogrammetry", + "description": "Zip an image folder and process through photogrammetry pipeline", + "version": "1", + "start": "log", + + "parameterSchema": { + "type": "object", + "properties": { + "sourceFolderPath": { + "type": "string", + "minLength": 1 + }, + "filetype": { + "type": "string", + "minLength": 1 + }, + "outputFileBaseName": { + "type": "string", + "minLength": 1 + }, + "alignFolderPath": { + "type": "string", + "minLength": 1 + }, + "maskFolderPath": { + "type": "string", + "minLength": 1 + }, + "tool": { + "type": "string", + "enum": [ + "Metashape", + "RealityCapture", + "Meshroom" + ], + "default": "Metashape" + }, + "scalebarCSV": { + "type": "string", + "minLength": 1, + "format": "file" + }, + "optimizeMarkers": { + "type": "boolean", + "default": false + }, + "alignmentLimit": { + "type": "number", + "default": 50, + "minimum": 0, + "maximum": 100 + }, + "convertToJpg": { + "type": "boolean", + "default": false + }, + "tiepointLimit": { + "type": "number", + "default": 25000, + "minimum": 1000, + "maximum": 50000 + }, + "keypointLimit": { + "type": "number", + "default": 75000, + "minimum": 1000, + "maximum": 120000 + }, + "turntableGroups": { + "type": "boolean", + "default": false + }, + "findTurntableCenter": { + "type": "boolean", + "default": false + }, + "saveScreenshot": { + "type": "boolean", + "default": false + }, + "genericPreselection": { + "type": "boolean", + "default": true + }, + "depthMaxNeighbors": { + "type": "number", + "default": 16, + "minimum": 4, + "maximum": 256 + }, + "meshQuality": { + "type": "string", + "enum": [ + "Low", + "Medium", + "High", + "Highest", + "Custom" + ], + "default": "High" + }, + "customFaceCount": { + "type": "number", + "default": 3000000 + }, + "depthMapQuality": { + "type": "string", + "enum": [ + "Low", + "Medium", + "High", + "Highest" + ], + "default": "Highest" + }, + "levelClipAlign": { + "type": "boolean", + "default": false + }, + "clipLevel": { + "type": "number", + "default": 150 + }, + "maskMode": { + "type": "string", + "enum": [ + "File", + "Background" + ], + "default": "File" + }, + "transportMethod": { + "type": "string", + "enum": [ + "none", + "local" + ], + "default": "none" + }, + "deliveryPath": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "sourceFolderPath" + ], + "advanced": [ + "alignmentLimit", "convertToJpg", "tiepointLimit", "keypointLimit", "turntableGroups", "genericPreselection", "depthMaxNeighbors", + "meshQuality", "customFaceCount", "depthMapQuality", "saveScreenshot", "levelClipAlign", "clipLevel", "maskMode" + ], + "additionalProperties": false + }, + + "steps": { + "log": { + "task": "Log", + "description": "Enable logging services", + "pre": { + "outputFileBaseName": "$baseName($firstTrue(outputFileBaseName, sourceFolderPath))", + "baseMeshName": "$firstTrue(outputFileBaseName, sourceFolderPath)", + "sourceFolderBaseName": "$baseName(sourceFolderPath)", + "doAlign": "$exists(alignFolderPath)", + "doMask": "$exists(maskFolderPath)" + }, + "parameters": { + "logToConsole": true, + "reportFile": "outputFileBaseName & '-report.json'" + }, + "success": "'zip-files'", + "failure": "$failure" + }, + "zip-files": { + "task": "Zip", + "description": "Zip files direct from storage", + "pre": { + "deliverables": { + "fileZip": "sourceFolderBaseName & '.zip'" + } + }, + "parameters": { + "inputFile1": "sourceFolderPath", + "fileFilter": "filetype", + "recursive": false, + "outputFile": "deliverables.fileZip", + "operation": "'path-zip'" + }, + "success": "'unzip'", + "failure": "$failure" + }, + "unzip": { + "task": "Zip", + "description": "Unzip image folder", + "parameters": { + "inputFile1": "deliverables.fileZip", + "operation": "'unzip'" + }, + "success": "'zip-align'", + "failure": "$failure" + }, + "zip-align": { + "task": "Zip", + "skip": "$not(doAlign)", + "description": "Zip alignment files direct from storage", + "pre": { + "alignFolderBaseName": "$baseName(alignFolderPath)", + "deliverables": { + "alignFileZip": "alignFolderBaseName & '.zip'" + } + }, + "parameters": { + "inputFile1": "alignFolderPath", + "fileFilter": "filetype", + "recursive": false, + "outputFile": "deliverables.alignFileZip", + "operation": "'path-zip'" + }, + "success": "'unzip-align'", + "failure": "$failure" + }, + "unzip-align": { + "task": "Zip", + "skip": "$not(doAlign)", + "description": "Unzip alignment image folder", + "parameters": { + "inputFile1": "deliverables.alignFileZip", + "operation": "'unzip'" + }, + "success": "'make-level-clip-folder'", + "failure": "$failure" + }, + "make-level-clip-folder": { + "task": "FileOperation", + "skip": "$not(levelClipAlign)", + "description": "Create folder for converted images", + "pre": { + "sourceFolderConverted": "'clipped_alignment'" + }, + "parameters": { + "operation": "'CreateFolder'", + "name": "sourceFolderConverted" + }, + "success": "'level-align'", + "failure": "$failure" + }, + "level-align": { + "task": "BatchConvertImage", + "skip": "$not(levelClipAlign)", + "description": "Generate level-clipped alignment images", + "parameters": { + "inputImageFolder": "sourceFolderBaseName", + "outputImageFolder": "sourceFolderConverted", + "filetype": "jpg", + "quality": "85", + "level": "clipLevel" + }, + "post": { + "alignFolderBaseName": "$baseName(sourceFolderConverted)" + }, + "success": "'zip-mask'", + "failure": "$failure" + }, + "zip-mask": { + "task": "Zip", + "skip": "$not(doMask)", + "description": "Zip mask files direct from storage", + "pre": { + "maskFolderBaseName": "$baseName(maskFolderPath)", + "deliverables": { + "maskFileZip": "maskFolderBaseName & '.zip'" + } + }, + "parameters": { + "inputFile1": "maskFolderPath", + "fileFilter": "filetype", + "recursive": false, + "outputFile": "deliverables.maskFileZip", + "operation": "'path-zip'" + }, + "success": "'unzip-mask'", + "failure": "$failure" + }, + "unzip-mask": { + "task": "Zip", + "skip": "$not(doMask)", + "description": "Unzip mask image folder", + "parameters": { + "inputFile1": "deliverables.maskFileZip", + "operation": "'unzip'" + }, + "success": "'make-convert-folder'", + "failure": "$failure" + }, + "make-convert-folder": { + "task": "FileOperation", + "skip": "$not(convertToJpg)", + "description": "Create folder for converted images", + "pre": { + "sourceFolderConverted": "sourceFolderBaseName & '_converted'" + }, + "parameters": { + "operation": "'CreateFolder'", + "name": "sourceFolderConverted" + }, + "success": "'convert-to-jpg'", + "failure": "$failure" + }, + "convert-to-jpg": { + "task": "BatchConvertImage", + "skip": "$not(convertToJpg)", + "description": "Convert images to .jpg", + "parameters": { + "inputImageFolder": "sourceFolderBaseName", + "outputImageFolder": "sourceFolderConverted", + "filetype": "jpg", + "quality": "85" + }, + "success": "'photogrammetry'", + "failure": "$failure" + }, + "photogrammetry": { + "task": "Photogrammetry", + "description": "Create mesh and texture from image set.", + "pre": { + "camerasFile": "baseMeshName & '-cameras.xml'", + "deliverables": { + "meshFile": "baseMeshName & '-' & $lowercase(tool) & '.obj'", + "textureFile": "baseMeshName & '-' & $lowercase(tool) & '.tif'", + "mtlFile": "baseMeshName & '-' & $lowercase(tool) & '.mtl'" + } + }, + "parameters": { + "inputImageFolder": "convertToJpg ? sourceFolderConverted : sourceFolderBaseName", + "alignImageFolder": "alignFolderBaseName", + "maskImageFolder": "maskFolderBaseName", + "camerasFile": "camerasFile", + "outputFile": "deliverables.meshFile", + "scalebarFile": "scalebarCSV", + "optimizeMarkers": "optimizeMarkers", + "alignmentLimit": "alignmentLimit", + "tiepointLimit": "tiepointLimit", + "keypointLimit": "keypointLimit", + "turntableGroups": "turntableGroups", + "genericPreselection": "genericPreselection", + "depthMaxNeighbors": "depthMaxNeighbors", + "meshQuality": "meshQuality", + "customFaceCount": "customFaceCount", + "depthMapQuality": "depthMapQuality", + "maskMode": "maskMode", + "tool": "tool", + "timeout": 86400 + }, + "success": "'rename'", + "failure": "$failure" + }, + "rename": { + "task": "FileOperation", + "description": "Rename Meshroom Model", + "skip": "$not(tool = 'Meshroom')", + "parameters": { + "operation": "'RenameFile'", + "name": "'texturedMesh.obj'", + "newName": "deliverables.meshFile" + }, + "success": "'inspect'", + "failure": "$failure" + }, + "inspect": { + "task": "InspectMesh", + "description": "Validate mesh and inspect topology", + "pre": { + "deliverables": { + "inspectionReport": "outputFileBaseName & '-inspection.json'" + } + }, + "parameters": { + "meshFile": "deliverables.meshFile", + "reportFile": "deliverables.inspectionReport", + "tool": "'Blender'" + }, + "post": { + "sceneSize": "$result.inspection.scene.geometry.size" + }, + "success": "'cleanup'", + "failure": "$failure" + }, + "cleanup": { + "task": "CleanupMesh", + "description": "Cleanup common issues with mesh.", + "pre": { + "deliverables": { + "cleanedMeshFile": "baseMeshName & '-cleaned' & '.obj'", + "cleanedMtlFile": "baseMeshName & '-cleaned' & '.obj.mtl'" + } + }, + "parameters": { + "inputMeshFile": "deliverables.meshFile", + "outputMeshFile": "deliverables.cleanedMeshFile", + "isTurntable": "findTurntableCenter", + "sceneSize": "sceneSize", + "timeout": 1200 + }, + "success": "'texture'", + "failure": "$failure" + }, + "texture": { + "task": "PhotogrammetryTex", + "skip": "$not(tool = 'Metashape')", + "description": "Create and map texture from model and image set.", + "pre": { + "deliverables": { + "finalMeshFile": "baseMeshName & '-' & $lowercase(tool) & '-raw_clean.obj'", + "finalTextureFile": "baseMeshName & '-' & $lowercase(tool) & '-raw_clean.tif'", + "finalMtlFile": "baseMeshName & '-' & $lowercase(tool) & '-raw_clean.mtl'" + } + }, + "parameters": { + "inputImageFolder": "convertToJpg ? sourceFolderConverted : sourceFolderBaseName", + "inputModelFile": "deliverables.cleanedMeshFile", + "camerasFile": "camerasFile", + "outputFile": "deliverables.finalMeshFile", + "scalebarFile": "scalebarCSV", + "tool": "tool", + "timeout": 86400 + }, + "success": "'screenshot'", + "failure": "$failure" + }, + "screenshot": { + "task": "Screenshot", + "skip": "$not(saveScreenshot)", + "description": "Generate screenshot of result geometry.", + "parameters": { + "inputMeshFile": "deliverables.finalMeshFile", + "timeout": 1200 + }, + "success": "'zip-proj-align'", + "failure": "$failure" + }, + "zip-proj-align": { + "task": "Zip", + "description": "Zip photogrammetry project files for align stage", + "pre": { + "deliverables": { + "alignProjZip": "baseMeshName & '-' & $lowercase(tool) & '-align.files.zip'", + "alignProjFile": "baseMeshName & '-' & $lowercase(tool) & '-align.psx'" + } + }, + "parameters": { + "inputFile1": "$jobDir & '\\\\' & baseMeshName & '-' & $lowercase(tool) & '-align.files'", + "fileFilter": "filetype", + "recursive": true, + "outputFile": "deliverables.alignProjZip", + "operation": "'path-zip'" + }, + "success": "'zip-proj-mesh'", + "failure": "$failure" + }, + "zip-proj-mesh": { + "task": "Zip", + "description": "Zip photogrammetry project files for mesh stage", + "pre": { + "deliverables": { + "meshProjZip": "baseMeshName & '-' & $lowercase(tool) & '-mesh.files.zip'", + "meshProjFile": "baseMeshName & '-' & $lowercase(tool) & '-mesh.psx'", + "meshProjReport": "baseMeshName & '-' & $lowercase(tool) & '-report.pdf'" + } + }, + "parameters": { + "inputFile1": "$jobDir & '\\\\' & baseMeshName & '-' & $lowercase(tool) & '-mesh.files'", + "fileFilter": "filetype", + "recursive": true, + "outputFile": "deliverables.meshProjZip", + "operation": "'path-zip'" + }, + "success": "'zip-proj-final'", + "failure": "$failure" + }, + "zip-proj-final": { + "task": "Zip", + "description": "Zip photogrammetry project files for final stage", + "pre": { + "deliverables": { + "finalProjZip": "baseMeshName & '-' & $lowercase(tool) & '-raw_clean.files.zip'", + "finalProjFile": "baseMeshName & '-' & $lowercase(tool) & '-raw_clean.psx'" + } + }, + "parameters": { + "inputFile1": "$jobDir & '\\\\' & baseMeshName & '-' & $lowercase(tool) & '-raw_clean.files'", + "fileFilter": "filetype", + "recursive": true, + "outputFile": "deliverables.finalProjZip", + "operation": "'path-zip'" + }, + "success": "'delivery'", + "failure": "$failure" + }, + "delivery": { + "task": "Delivery", + "description": "Send result files back to client", + "parameters": { + "method": "transportMethod", + "path": "$firstTrue(deliveryPath, pickupPath, $currentDir)", + "files": "deliverables" + }, + "success": "$success", + "failure": "$failure" + } + } +} \ No newline at end of file diff --git a/server/scripts/BlenderScreenshot.py b/server/scripts/BlenderScreenshot.py new file mode 100644 index 0000000..21a1e84 --- /dev/null +++ b/server/scripts/BlenderScreenshot.py @@ -0,0 +1,52 @@ +import bpy +import json +import os +import sys + +# get rid of default mesh objects +for ob in bpy.context.scene.objects: + if ob.type == 'MESH': + ob.select_set(True) + +#bpy.ops.object.select_all(action='SELECT') +bpy.ops.object.delete(use_global=False) +bpy.ops.outliner.orphans_purge() +bpy.ops.outliner.orphans_purge() +bpy.ops.outliner.orphans_purge() + +#get args +argv = sys.argv +argv = argv[argv.index("--") + 1:] + +#get import file extension +filename, file_extension = os.path.splitext(argv[0]) +file_extension = file_extension.lower() + +#import scene +if file_extension == '.obj': + bpy.ops.wm.obj_import(filepath=argv[0]) +elif file_extension == '.ply': + bpy.ops.import_mesh.ply(filepath=argv[0]) +elif file_extension == '.stl': + bpy.ops.import_mesh.stl(filepath=argv[0]) +elif file_extension == '.x3d': + bpy.ops.import_scene.x3d(filepath=argv[0]) +elif file_extension == '.dae': + bpy.ops.wm.collada_import(filepath=argv[0]) +elif file_extension == '.fbx': + bpy.ops.import_scene.fbx(filepath=argv[0]) +elif file_extension == '.glb' or file_extension == '.gltf': + bpy.ops.import_scene.gltf(filepath=argv[0]) +else: + print("Error: Unsupported file type: " + file_extension) + sys.exit(1) + +if len(bpy.data.objects) > 0: + bpy.context.scene.camera = bpy.context.scene.objects.get('Camera') + dir = os.path.dirname(filename) + save_file = os.path.join(dir, "preview.png") + print("Writing preview image: " + save_file) + bpy.ops.view3d.camera_to_view_selected() + +bpy.context.scene.render.filepath = save_file +bpy.ops.render.render(write_still = True) diff --git a/server/scripts/MetashapeGenerateMesh.py b/server/scripts/MetashapeGenerateMesh.py index 635f032..c9fc6ad 100644 --- a/server/scripts/MetashapeGenerateMesh.py +++ b/server/scripts/MetashapeGenerateMesh.py @@ -2,8 +2,10 @@ import csv import sys import os +import math import argparse from os import walk, path +from statistics import mean, pstdev, variance def convert(s): if s.lower() == "true": @@ -11,6 +13,128 @@ def convert(s): else: return False +def mag(x): + return math.sqrt(sum(i**2 for i in x)) + +def findLowProjectionCameras(chunk, cameras, limit): + point_cloud = chunk.tie_points + projections = point_cloud.projections + points = point_cloud.points + npoints = len(points) + tracks = point_cloud.tracks + point_ids = [-1] * len(point_cloud.tracks) + + for point_id in range(0, npoints): + point_ids[points[point_id].track_id] = point_id + + for camera in cameras: + nprojections = 0 + + if not camera.transform: + camera.enabled = False + print(camera, "NO ALIGNMENT") + continue + + for proj in projections[camera]: + track_id = proj.track_id + point_id = point_ids[track_id] + if point_id < 0: + continue + if not points[point_id].valid: + continue + + nprojections += 1 + + if nprojections <= limit: + camera.enabled = False + print(camera, nprojections, len(projections[camera])) + +def matrixFromAxisAngle(axis, angle): + + c = math.cos(angle) + s = math.sin(angle) + t = 1.0 - c + + m00 = c + axis[0]*axis[0]*t + m11 = c + axis[1]*axis[1]*t + m22 = c + axis[2]*axis[2]*t + + tmp1 = axis[0]*axis[1]*t + tmp2 = axis[2]*s + m10 = tmp1 + tmp2 + m01 = tmp1 - tmp2 + tmp1 = axis[0]*axis[2]*t + tmp2 = axis[1]*s + m20 = tmp1 - tmp2 + m02 = tmp1 + tmp2 + tmp1 = axis[1]*axis[2]*t + tmp2 = axis[0]*s + m21 = tmp1 + tmp2 + m12 = tmp1 - tmp2 + + return Metashape.Matrix([[m00, m01, m02],[m10, m11, m12],[m20, m21, m22]]) + +def center_of_geometry_to_origin(chunk): + model = chunk.model + if not model: + print("No model in chunk, script aborted") + return 0 + vertices = model.vertices + T = chunk.transform.matrix + + minx = vertices[0].coord[0] + maxx = vertices[0].coord[0] + miny = vertices[0].coord[1] + maxy = vertices[0].coord[1] + minz = vertices[0].coord[2] + maxz = vertices[0].coord[2] + for i in range(0, len(vertices)): + minx = min(minx,vertices[i].coord[0]) + maxx = max(maxx,vertices[i].coord[0]) + miny = min(miny,vertices[i].coord[1]) + maxy = max(maxy,vertices[i].coord[1]) + minz = min(minz,vertices[i].coord[2]) + maxz = max(maxz,vertices[i].coord[2]) + print(minx,maxx,miny,maxy,minz,maxz) + avg = Metashape.Vector([(minx+maxx)/2.0,(miny+maxy)/2.0,(minz+maxz)/2.0]) + #chunk.region.center = avg + chunk.transform.translation = chunk.transform.translation - T.mulp(avg) + +def model_to_origin(chunk, camera_refs, name): + model = chunk.model + if not model: + print("No model in chunk, script aborted") + return 0 + T = chunk.transform.matrix + + local_centers = [] + for group in camera_refs.keys(): + if group == name: + camera_count = 0 + + pos_avg = [0,0,0] + for camera in camera_refs[group]: + if camera.center != None: + camera_count += 1 + for i, bi in enumerate(camera.center): pos_avg[i] += bi + if camera_count > 0: + pos_avg[0] /= camera_count + pos_avg[1] /= camera_count + pos_avg[2] /= camera_count + local_centers.append(pos_avg) + print("MESH ALIGN: ", T.mulp(Metashape.Vector(local_centers[0]))) + print(chunk.transform.translation, Metashape.Vector(local_centers[0])) + chunk.transform.translation = chunk.transform.translation - T.mulp(Metashape.Vector(local_centers[0])) + +def get_background_masks(mask_path): + masks = [] + for r, d, f in walk(mask_path): + for file in f: + filename = os.path.splitext(file)[0] + delimeter = filename[filename.rfind("-"):] + masks.append({"key":delimeter,"name":file}) + return masks + #get args argv = sys.argv @@ -19,9 +143,20 @@ def convert(s): parser.add_argument("-i", "--input", required=True, help="Input filepath") parser.add_argument("-c", "--cameras", required=True, help="Cameras filepath") parser.add_argument("-o", "--output", required=True, help="Output filename") +parser.add_argument("-ai", "--align_input", required=False, help="Alignment input filepath") +parser.add_argument("-mi", "--mask_input", required=False, help="Mask input filepath") +parser.add_argument("-mm", "--mask_mode", required=False, help="Masking mode") +parser.add_argument("-al", "--align_limit", required=False, help="Alignment threshold (%)") parser.add_argument("-sb", required=False, help="Scalebar definition file") parser.add_argument("-optm", required=False, default="False", help="Optimize markers") -parser.add_argument("-bdc", required=False, default="False", help="Build dense cloud") +parser.add_argument("-tp", required=False, default=25000, help="Tiepoint limit") +parser.add_argument("-kp", required=False, default=75000, help="Keypoint limit") +parser.add_argument("-gp", required=False, default="True", help="Generic preselection") +parser.add_argument("-dmn", required=False, default=16, help="Depth map max neighbors") +parser.add_argument("-ttg", required=False, default="False", help="Process turntable groups") +parser.add_argument("-mq", required=False, default=2, help="Model resolution quality") +parser.add_argument("-cfc", required=False, default=3000000, help="Custom model face count") +parser.add_argument("-dmq", required=False, default=0, help="Depth map quality") args = parser.parse_args() doc = Metashape.app.document @@ -29,7 +164,11 @@ def convert(s): imagePath = args.input camerasPath = args.cameras -name = os.path.basename(os.path.normpath(imagePath)) +processGroups = convert(args.ttg) +filterMask = args.mask_input != None +genericPreselection = convert(args.gp) +basename = os.path.basename(os.path.normpath(args.output)) +basename = os.path.splitext(basename)[0]; # Grab images from directory (include subdirectories) imageFiles=[] @@ -37,6 +176,9 @@ def convert(s): for i, file in enumerate(f): imageFiles.append(os.path.join(r, file)) +# get image extension +imageExt = os.path.splitext(imageFiles[0])[1] + # set 'Scale Bar Accuracy' to 0.0001 chunk.scalebar_accuracy = 0.0001 # set 'Tie Point Accuracy' to 0.1 @@ -44,19 +186,90 @@ def convert(s): # set 'Marker Projection Accuracy' to 0.1 chunk.marker_projection_accuracy = 0.1 +# Add optional alignment images +camera_groups = {} +alignImages=[] +alignCameras=[] +if args.align_input != None: + alignPath = args.align_input + camera_group = chunk.addCameraGroup() + camera_group.label = "alignment_images" + camera_groups["alignment_images"] = camera_group + for r, d, f in walk(alignPath): + for i, file in enumerate(f): + alignImages.append(os.path.join(r, file)) + chunk.addPhotos(alignImages) + for photo in chunk.cameras: + if photo.group == None: + photo.group = camera_group + alignCameras.append(photo) + # Add photos chunk.addPhotos(imageFiles) +# Sort into camera groups (if needed) +camera_refs = dict() +if processGroups == True: + for photo in chunk.cameras: + if photo.group == None: + name = str(photo.label) + # Remove the sequence number from the base name (CaptureOne Pro formatting) + base_name_without_sequence_number = name[0:name.rfind("-")] + #print(name + " --> " + base_name_without_sequence_number) + + # If this naming pattern doesn't have a camera group yet, create one + if base_name_without_sequence_number not in camera_groups: + camera_group = chunk.addCameraGroup() + camera_group.label = base_name_without_sequence_number + camera_groups[base_name_without_sequence_number] = camera_group + camera_refs[base_name_without_sequence_number] = [] + + # Add the camera to the appropriate camera group + photo.group = camera_groups[base_name_without_sequence_number] + + camera_refs[base_name_without_sequence_number].append(photo) + +# Add/generate masks +if args.mask_input != None: + mask_count = len([name for name in os.listdir(args.mask_input+"\\") if os.path.isfile(args.mask_input+"\\"+name)]) + # determine mask mode + if args.mask_mode == "Background": + mask_mode = Metashape.MaskingMode.MaskingModeBackground + else: + mask_mode = Metashape.MaskingMode.MaskingModeFile + print("Number of masks", mask_count) + try: + if mask_count > 10: # assumes per-image mask + chunk.generateMasks(path=args.mask_input+"\\{filename}"+imageExt, masking_mode=mask_mode) + else: # otherwise generate device specific masks + masks = get_background_masks(args.mask_input) + for mask in masks: + key = mask["key"] + camera_filter = list(filter(lambda x: key in x.label, chunk.cameras)) + chunk.generateMasks \ + ( + path=args.mask_input+"\\"+mask["name"], + masking_mode=mask_mode, + mask_operation=Metashape.MaskOperationReplacement, + tolerance=30, + cameras=camera_filter, + mask_defocus=False, + fix_coverage=True, + ) + + except: + print("Warning: Mask generation error!") + chunk.matchPhotos\ ( - downscale=0, - generic_preselection=True, + downscale=1, + generic_preselection=genericPreselection, reference_preselection=False, #reference_preselection_mode=Metashape.ReferencePreselectionSource, - filter_mask=False, + filter_mask=filterMask, mask_tiepoints=False, - keypoint_limit=40000, - tiepoint_limit=4000, + keypoint_limit=args.kp, + tiepoint_limit=args.tp, keep_keypoints=False, guided_matching=False, reset_matches=False @@ -65,11 +278,181 @@ def convert(s): # align the matched image pairs chunk.alignCameras() +# evaluate alignment based on groups +if processGroups == True: + + findLowProjectionCameras(chunk, chunk.cameras, 100) + + # Sort cameras and reset bad ones + good_cameras = [] + bad_cameras = [] + for camera in chunk.cameras: + if camera.enabled == False: + bad_cameras.append(camera) + else: + good_cameras.append(camera) + print(len(bad_cameras)) + for camera in bad_cameras: + camera.transform = None + + chunk.optimizeCameras( adaptive_fitting=True ) + + # Try to realign flagged cameras + for camera in bad_cameras: + camera.enabled = True + chunk.alignCameras([camera]) + + findLowProjectionCameras(chunk, bad_cameras, 100) + + # Try to realign again for good measure + for camera in bad_cameras: + if camera.enabled == False: + camera.transform = None + camera.enabled = True + chunk.alignCameras([camera]) + + findLowProjectionCameras(chunk, bad_cameras, 20) + + bad_cameras = [] + for camera in chunk.cameras: + if camera.enabled == False: + bad_cameras.append(camera) + camera.transform = None + print("FLAGGED BAD CAMERAS: ", len(bad_cameras)) + + # compute overall mean deviation + tot_avg = [0,0,0] + tot_dev = [] + tot_count = 0 + for camera in chunk.cameras: + if camera.center != None: + tot_count += 1 + for i, bi in enumerate(camera.center): tot_avg[i] += bi + else: + camera.enabled = False + tot_avg[0] /= tot_count + tot_avg[1] /= tot_count + tot_avg[2] /= tot_count + for camera in chunk.cameras: + if camera.center != None: + camera_err = [0,0,0] + camera_err[0] = camera.center[0] - tot_avg[0] + camera_err[1] = camera.center[1] - tot_avg[1] + camera_err[2] = camera.center[2] - tot_avg[2] + tot_dev.append(mag(camera_err)) + avg_dev = mean(tot_dev) + #print("AVG DEV: "+str(avg_dev)) + + # Identify cameras that are too tightly clustered within a group (currently disabled) + local_centers = [] + for group in camera_refs.keys(): + camera_count = 0 + + pos_avg = [0,0,0] + for camera in camera_refs[group]: + if camera.center != None: + camera_count += 1 + for i, bi in enumerate(camera.center): pos_avg[i] += bi + if camera_count > 0: + pos_avg[0] /= camera_count + pos_avg[1] /= camera_count + pos_avg[2] /= camera_count + local_centers.append({"name": group, "ctr": pos_avg}) + else: + print("ERROR - no cameras aligned!!!") + + loc_dev_arr = [] + for camera in camera_refs[group]: + if camera.center != None: + camera_err = [0,0,0] + camera_err[0] = camera.center[0] - pos_avg[0] + camera_err[1] = camera.center[1] - pos_avg[1] + camera_err[2] = camera.center[2] - pos_avg[2] + loc_dev_arr.append(mag(camera_err)) + # if mag(camera_err) < avg_dev * 0.1: + # if camera.enabled != False: + # camera.enabled = False + # bad_cameras.append(camera) + + #chunk.remove(bad_cameras) + + # calculate near and far ring centers + if len(local_centers) > 1: + chunk_ctr = chunk.region.center + ring_sort_arr = [] + filtered_ctrs = list(filter(lambda x: "-s01" in x["name"], local_centers)) + for center in filtered_ctrs: + aligned = [camera for camera in camera_refs[center["name"]] if camera.transform and camera.type==Metashape.Camera.Type.Regular] + success_ratio = len(aligned) / len(camera_refs[center["name"]]) * 100 + if success_ratio > 75: + dist = mag([chunk_ctr[0] - center["ctr"][0], chunk_ctr[1] - center["ctr"][1], chunk_ctr[2] - center["ctr"][2]]) + ring_sort_arr.append({"name": center["name"], "distance": dist}) + + # find far idx + direction = 0 + for idx in range(len(ring_sort_arr)-1): + if ring_sort_arr[idx+1]["distance"] > ring_sort_arr[idx]["distance"]: + direction += 1 + else: + direction -= 1 + + if direction > 0: + far_idx = len(ring_sort_arr)-1 + elif direction < 0: + far_idx = 0 + else: + far_idx = 0 + print("Warning: Could not find approriate capture ring for alignment. Using first encountered.") + + far_center = next(x for x in local_centers if x["name"] == ring_sort_arr[far_idx]["name"])["ctr"] + if far_idx > 0: + near_center = next(x for x in local_centers if x["name"] == ring_sort_arr[far_idx-1]["name"])["ctr"] + else: + near_center = next(x for x in local_centers if x["name"] == ring_sort_arr[far_idx+1]["name"])["ctr"] + align_ring_name = ring_sort_arr[far_idx]["name"] + print("Info: Using " + align_ring_name + " for axis alignment") + else: + near_center = chunk.region.center + far_center = local_centers[0]["ctr"] + print("Info: Using chunk center for axis alignment") + + # calculate rotation offset to up vector + curr_dir = Metashape.Vector(far_center) - Metashape.Vector(near_center) + curr_dir = curr_dir.normalized() + angle = math.acos(sum( [curr_dir[i]*[0,0,1][i] for i in range(len([0,0,1]))] )) + axis = Metashape.Vector.cross(curr_dir,[0,0,1]).normalized() + + rot_offset = matrixFromAxisAngle(axis, angle) + + R = chunk.region.rot*(rot_offset*chunk.region.rot.inv()) # Bounding box rotation matrix + C = chunk.region.center # Bounding box center vector + T = Metashape.Matrix( [[R[0,0], R[0,1], R[0,2], C[0]], [R[1,0], R[1,1], R[1,2], C[1]], [R[2,0], R[2,1], R[2,2], C[2]], [0, 0, 0, 1]]) + + chunk.transform.matrix = Metashape.Matrix.Rotation(rot_offset)*Metashape.Matrix.Translation(C).inv() #T.inv() + + camera_ctr = chunk.transform.matrix.mulp(Metashape.Vector(near_center)) + chunk.transform.matrix = chunk.transform.matrix*Metashape.Matrix.Translation(Metashape.Vector([camera_ctr[0], camera_ctr[1], 0])).inv() + +# disable alignment-only cameras +if args.align_input != None: + for camera in alignCameras: + camera.enabled = False + +# save post-alignment +doc.save(imagePath+"\\..\\"+basename+"-align.psx") +chunk = doc.chunks[0] + +aligned = [camera for camera in chunk.cameras if camera.transform and camera.type==Metashape.Camera.Type.Regular] +success_ratio = len(aligned) / len(chunk.cameras) * 100 +print("ALIGNMENT SUCCESS: "+str(success_ratio)) + +# exit out if alignment is less than requirement +if success_ratio < int(args.align_limit): + sys.exit("Error: Image alignment does not meet minimum threshold") + +#sys.exit(1) # optimize cameras -chunk.optimizeCameras\ -( - adaptive_fitting=True -) +chunk.optimizeCameras( adaptive_fitting=True ) if args.sb != None: ## Detect markers @@ -139,41 +522,24 @@ def convert(s): # Ultrahigh setting loads the image data at full resolution, High downsamples x2, medium downsamples x4, low x8 chunk.buildDepthMaps\ ( - downscale=1, + downscale=pow(2,int(args.dmq)), filter_mode=Metashape.MildFiltering, reuse_depth=False, - max_neighbors=16, + max_neighbors=args.dmn, subdivide_task=True, workitem_size_cameras=20, max_workgroup_size=100 ) -denseCloudFlag = convert(args.bdc); -if denseCloudFlag == True: - # build dense cloud - # the quality of the dense cloud is determined by the quality of the depth maps - # "max_neighbors" value of '-1' will evaluate ALL IMAGES in parallel. 200-300 is good when there is a lot of image overlap. - # setting this value will fix an issue where there is excessive 'fuzz' in the dense cloud. the default value is 100. - chunk.buildDenseCloud\ - ( - point_colors=True, - point_confidence=True, - keep_depth=True, - max_neighbors=300, - subdivide_task=True, - workitem_size_cameras=20, - max_workgroup_size=100 - ) - -modelQuality = [Metashape.FaceCount.HighFaceCount] +modelQuality = [Metashape.FaceCount.LowFaceCount, Metashape.FaceCount.MediumFaceCount, Metashape.FaceCount.HighFaceCount, Metashape.FaceCount.CustomFaceCount] chunk.buildModel\ ( surface_type=Metashape.Arbitrary, interpolation=Metashape.DisabledInterpolation, - face_count=modelQuality[0], - face_count_custom=200000, - source_data = Metashape.DenseCloudData if denseCloudFlag == True else Metashape.DepthMapsData, + face_count = modelQuality[3] if int(args.mq) < 0 else modelQuality[int(args.mq)], + face_count_custom = 0 if int(args.mq) < 0 else args.cfc, + source_data = Metashape.DepthMapsData, vertex_colors=False, vertex_confidence=True, volumetric_masks=False, @@ -204,6 +570,10 @@ def convert(s): chunk.updateTransform() +if processGroups == True: + # Move model to center + model_to_origin(chunk, camera_refs, align_ring_name) + chunk.exportModel\ ( path=imagePath+"\\..\\"+args.output, @@ -213,7 +583,7 @@ def convert(s): save_texture=True, save_uv=True, save_normals=True, - save_colors=True, + save_colors=False, save_cameras=True, save_markers=True, save_udim=False, @@ -226,6 +596,13 @@ def convert(s): format=Metashape.ModelFormatOBJ, ) +# remove alignment-only cameras +if args.align_input != None: + for camera in chunk.cameras: + if camera.group != None and camera.group.label == "alignment_images": + chunk.remove(camera) + chunk.exportCameras(camerasPath) +chunk.exportReport(imagePath+"\\..\\"+basename+"-report.pdf") -doc.save(imagePath+"\\..\\"+name+".psx") +doc.save(imagePath+"\\..\\"+basename+"-mesh.psx", [chunk]) diff --git a/server/scripts/MetashapeGenerateTexture.py b/server/scripts/MetashapeGenerateTexture.py index 109117d..de983bf 100644 --- a/server/scripts/MetashapeGenerateTexture.py +++ b/server/scripts/MetashapeGenerateTexture.py @@ -29,7 +29,8 @@ def convert(s): imagePath = args.input modelPath = args.model camerasPath = args.cameras -name = os.path.basename(os.path.normpath(imagePath)) +name = os.path.basename(os.path.normpath(args.output)) +name = os.path.splitext(name)[0]; # Grab images from directory (include subdirectories) imageFiles=[] @@ -92,4 +93,4 @@ def convert(s): format=Metashape.ModelFormatOBJ, ) -doc.save(imagePath+"\\..\\"+name+"-final.psx") +doc.save(imagePath+"\\..\\"+name+".psx") diff --git a/source/server/app/ApiRouter.ts b/source/server/app/ApiRouter.ts index 1afc486..3205217 100644 --- a/source/server/app/ApiRouter.ts +++ b/source/server/app/ApiRouter.ts @@ -160,6 +160,8 @@ export default class ApiRouter // machine state this.router.get("/machine", (req, res) => { const state = jobManager.getState(); + res.set('Access-Control-Allow-Origin', '*'); + res.set('Access-Control-Allow-Private-Network', 'true'); return res.json(state); }) } diff --git a/source/server/tasks/BatchConvertImageTask.ts b/source/server/tasks/BatchConvertImageTask.ts new file mode 100644 index 0000000..0e72458 --- /dev/null +++ b/source/server/tasks/BatchConvertImageTask.ts @@ -0,0 +1,88 @@ +/** + * 3D Foundation Project + * Copyright 2023 Smithsonian Institution + * + * 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. + */ + +import Job from "../app/Job"; + +import { IImageMagickToolSettings } from "../tools/ImageMagickTool"; + +import Task, { ITaskParameters } from "../app/Task"; +import ToolTask from "../app/ToolTask"; + +//////////////////////////////////////////////////////////////////////////////// + +/** Parameters for [[BatchConvertImageTask]]. */ +export interface IBatchConvertImageTaskParameters extends ITaskParameters +{ + /** Input image folder path. */ + inputImageFolder: string; + /** Output image folder path. */ + outputImageFolder: string; + /** Compression quality for JPEG images (0 - 100, default: 70). */ + quality?: number; + /** Filetype to convert images to. */ + filetype?: string; + /** Clips image to black (value < 128) or white (value > 128) */ + level?: number; +} + +/** + * Converts folders image files between different formats. + * + * Parameters: [[IBatchConvertImageTaskParameters]]. + * Tool: [[ImageMagickTool]]. + */ +export default class BatchConvertImageTask extends ToolTask +{ + static readonly taskName = "BatchConvertImage"; + + static readonly description = "Converts folders image files between different formats. "; + + static readonly parameterSchema = { + type: "object", + properties: { + inputImageFolder: { type: "string", minLength: 1 }, + outputImageFolder: { type: "string", minLength: 1 }, + quality: { type: "integer", minimum: 0, maximum: 100, default: 70 }, + filetype: { type: "string", default: "jpg" }, + level: { type: "integer", minimum: 0, maximum: 255} + }, + required: [ + "inputImageFolder", + "outputImageFolder", + "filetype" + ], + additionalParameters: false + }; + + static readonly parameterValidator = + Task.jsonValidator.compile(BatchConvertImageTask.parameterSchema); + + constructor(params: IBatchConvertImageTaskParameters, context: Job) + { + super(params, context); + + const settings: IImageMagickToolSettings = { + inputImageFolder: params.inputImageFolder, + outputImageFolder: params.outputImageFolder, + quality: params.quality, + batchConvertType: params.filetype, + level: params.level + }; + + this.addTool("ImageMagick", settings); + } +} \ No newline at end of file diff --git a/source/server/tasks/CleanupMeshTask.ts b/source/server/tasks/CleanupMeshTask.ts index 3441255..a6f15b9 100644 --- a/source/server/tasks/CleanupMeshTask.ts +++ b/source/server/tasks/CleanupMeshTask.ts @@ -37,6 +37,10 @@ export interface ICleanupMeshTaskParameters extends ITaskParameters computeVertexNormals?: boolean; /** Meshlab only: Removes everything but the largest connected component. */ keepLargestComponent?: boolean; + /** Flag to enable optimizations for turntable captures. */ + isTurntable?: boolean; + /** String containing scene dimensions */ + sceneSize?: number[]; /** Maximum task execution time in seconds (default: 0, uses timeout defined in tool setup, see [[IToolConfiguration]]). */ timeout?: number; } @@ -66,7 +70,9 @@ export default class CleanupMeshTask extends ToolTask preserveTexCoords: { type: "boolean", default: true }, computeVertexNormals: { type: "boolean", default: true }, keepLargestComponent: { type: "boolean", default: true }, - timeout: { type: "integer", default: 0 } + isTurntable: { type: "boolean", default: false }, + timeout: { type: "integer", default: 0 }, + sceneSize: { type: "array" } }, required: [ "inputMeshFile", @@ -113,6 +119,36 @@ export default class CleanupMeshTask extends ToolTask timeout: params.timeout }; + if(params.isTurntable) { + settings.filters.unshift( + /*{ + name: "CenterScene", + params: { + "traslMethod": 'Center on Scene BBox' + } + },*/ + { + name: "ConditionalFaceSelect", + params: { + "condSelect": 'abs(x0)<'+0.005+' && abs(y0)<'+0.005 //+' && abs(z0)<'+params.sceneSize[2]*0.1 + } + }, + { + name: "SelectConnectedFaces" + }, + { + name: "InvertSelection", + params: { + "InvFaces": true, + "InvVerts": false + } + }, + { + name: "DeleteSelected" + } + ); + } + this.addTool("Meshlab", settings); } } \ No newline at end of file diff --git a/source/server/tasks/FileOperationTask.ts b/source/server/tasks/FileOperationTask.ts index 647b0b2..e7af9c2 100644 --- a/source/server/tasks/FileOperationTask.ts +++ b/source/server/tasks/FileOperationTask.ts @@ -55,7 +55,7 @@ export default class FileOperationTask extends Task properties: { operation: { type: "string", enum: [ "DeleteFile", "RenameFile", "CreateFolder", "DeleteFolder" ]}, name: { type: "string", minLength: 1 }, - newName: { type: "string", minLength: 1, default: "" } + newName: { type: "string", minLength: 1 } }, required: [ "operation", diff --git a/source/server/tasks/MergeMeshTask.ts b/source/server/tasks/MergeMeshTask.ts index 97661f6..2e9986e 100644 --- a/source/server/tasks/MergeMeshTask.ts +++ b/source/server/tasks/MergeMeshTask.ts @@ -59,7 +59,8 @@ export default class MergeMeshTask extends ToolTask }, required: [ "inputMeshFile", - "outputMeshFile" + "outputMeshFile", + "outputTextureFile" ], additionalProperties: false }; diff --git a/source/server/tasks/PhotogrammetryTask.ts b/source/server/tasks/PhotogrammetryTask.ts index 740c243..40edb7f 100644 --- a/source/server/tasks/PhotogrammetryTask.ts +++ b/source/server/tasks/PhotogrammetryTask.ts @@ -32,16 +32,38 @@ export interface IPhotogrammetryTaskParameters extends ITaskParameters { /** Input image folder. */ inputImageFolder: string; + /** Alignment image folder. */ + alignImageFolder?: string; + /** Mask image folder. */ + maskImageFolder?: string; /** Base name used for output files */ outputFile: string; /** Name used for saved camera position file */ camerasFile: string; /** CSV file with scalebar markers and distances */ scalebarFile: string; - /** Flag to enable building a dense point cloud */ - generatePointCloud: boolean; /** Flag to enable discarding high-error markers */ optimizeMarkers: boolean; + /** Percent success required to pass alignment stage */ + alignmentLimit?: number; + /** Max number of tiepoints */ + tiepointLimit?: number; + /** Max number of keypoints */ + keypointLimit?: number; + /** Flag to process images as SI-formatted turntable groups */ + turntableGroups?: boolean; + /** Max neighbors value to use for depth map generation in Metashape */ + depthMaxNeighbors?: number; + /** Flag = true to use generic preselection in Metashape */ + genericPreselection?: boolean; + /** Preset for mesh quality ("Low", "Medium", "High", "Highest", "Custom") */ + meshQuality?: string; + /** If meshQuality is custom, this defines the goal face count */ + customFaceCount?: number; + /** Preset for depth map quality ("Low", "Medium", "High", "Highest") */ + depthMapQuality?: string; + /** Desired masking operation */ + maskMode?: "File" | "Background"; /** Maximum task execution time in seconds (default: 0, uses timeout defined in tool setup, see [[IToolConfiguration]]). */ timeout?: number; /** Tool to use for photogrammetry ("Metashape" or "RealityCapture" or "Meshroom", default: "Metashape"). */ @@ -64,11 +86,22 @@ export default class PhotogrammetryTask extends ToolTask type: "object", properties: { inputImageFolder: { type: "string", minLength: 1 }, + alignImageFolder: { type: "string", minLength: 1 }, + maskImageFolder: { type: "string", minLength: 1 }, outputFile: { type: "string", minLength: 1 }, camerasFile: { type: "string", minLength: 1 }, scalebarFile: { type: "string", minLength: 1 }, - generatePointCloud: { type: "boolean", default: false}, optimizeMarkers: { type: "boolean", default: false}, + alignmentLimit: { type: "number", default: 50}, + tiepointLimit: { type: "integer", default: 25000}, + keypointLimit: { type: "integer", default: 75000}, + turntableGroups: { type: "boolean", default: false}, + depthMaxNeighbors: { type: "integer", default: 16}, + genericPreselection: { type: "boolean", default: true}, + meshQuality: { type: "string", enum: [ "Low", "Medium", "High", "Highest", "Custom" ], default: "High"}, + customFaceCount: { type: "integer", default: 3000000}, + depthMapQuality: { type: "string", enum: [ "Low", "Medium", "High", "Highest" ], default: "Highest"}, + maskMode: { type: "string", enum: [ "File", "Background" ], default: "File"}, timeout: { type: "integer", default: 0 }, tool: { type: "string", enum: [ "Metashape", "RealityCapture", "Meshroom" ], default: "Metashape" } }, @@ -89,11 +122,22 @@ export default class PhotogrammetryTask extends ToolTask if (params.tool === "Metashape") { const toolOptions: IMetashapeToolSettings = { imageInputFolder: params.inputImageFolder, + alignImageFolder: params.alignImageFolder, + maskImageFolder: params.maskImageFolder, outputFile: params.outputFile, camerasFile: params.camerasFile, scalebarFile: params.scalebarFile, - generatePointCloud: params.generatePointCloud, optimizeMarkers: params.optimizeMarkers, + alignmentLimit: params.alignmentLimit, + tiepointLimit: params.tiepointLimit, + keypointLimit: params.keypointLimit, + turntableGroups: params.turntableGroups, + depthMaxNeighbors: params.depthMaxNeighbors, + genericPreselection: params.genericPreselection, + meshQuality: params.meshQuality, + customFaceCount: params.customFaceCount, + depthMapQuality: params.depthMapQuality, + maskMode: params.maskMode, mode: "full", timeout: params.timeout }; @@ -105,7 +149,6 @@ export default class PhotogrammetryTask extends ToolTask imageInputFolder: params.inputImageFolder, outputFile: params.outputFile, scalebarFile: params.scalebarFile, - generatePointCloud: params.generatePointCloud, timeout: params.timeout }; @@ -116,7 +159,6 @@ export default class PhotogrammetryTask extends ToolTask imageInputFolder: params.inputImageFolder, outputFile: params.outputFile, scalebarFile: params.scalebarFile, - generatePointCloud: params.generatePointCloud, timeout: params.timeout }; diff --git a/source/server/tasks/PhotogrammetryTexTask.ts b/source/server/tasks/PhotogrammetryTexTask.ts index 9841789..a2129c3 100644 --- a/source/server/tasks/PhotogrammetryTexTask.ts +++ b/source/server/tasks/PhotogrammetryTexTask.ts @@ -103,7 +103,6 @@ export default class PhotogrammetryTexTask extends ToolTask imageInputFolder: params.inputImageFolder, outputFile: params.outputFile, scalebarFile: params.scalebarFile, - generatePointCloud: params.generatePointCloud, timeout: params.timeout }; @@ -114,7 +113,6 @@ export default class PhotogrammetryTexTask extends ToolTask imageInputFolder: params.inputImageFolder, outputFile: params.outputFile, scalebarFile: params.scalebarFile, - generatePointCloud: params.generatePointCloud, timeout: params.timeout }; diff --git a/source/server/tasks/ScreenshotTask.ts b/source/server/tasks/ScreenshotTask.ts new file mode 100644 index 0000000..2b1a372 --- /dev/null +++ b/source/server/tasks/ScreenshotTask.ts @@ -0,0 +1,75 @@ +/** + * 3D Foundation Project + * Copyright 2023 Smithsonian Institution + * + * 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. + */ + +import Job from "../app/Job"; + +import { IBlenderToolSettings } from "../tools/BlenderTool"; + +import Task, { ITaskParameters } from "../app/Task"; +import ToolTask from "../app/ToolTask"; + +//////////////////////////////////////////////////////////////////////////////// + +/** Parameters for [[ScreenshotTask]]. */ +export interface IScreenshotTaskParameters extends ITaskParameters +{ + /** Input mesh file name. */ + inputMeshFile: string; + /** Maximum task execution time in seconds (default: 0, uses timeout defined in tool setup, see [[IToolConfiguration]]). */ + timeout?: number; +} + +/** + * Generates a screenshot of the provided geometry + * + * Parameters: [[IScreenshotTaskParameters]]. + * Tool: [[BlenderTool]]. + */ +export default class ScreenshotTask extends ToolTask +{ + static readonly taskName = "Screenshot"; + + static readonly description = "Generates a screenshot of the provided geometry"; + + static readonly parameterSchema = { + type: "object", + properties: { + inputMeshFile: { type: "string", minLength: 1 }, + timeout: { type: "integer", default: 0 } + }, + required: [ + "inputMeshFile" + ], + additionalProperties: false + }; + + static readonly parameterValidator = + Task.jsonValidator.compile(ScreenshotTask.parameterSchema); + + constructor(params: IScreenshotTaskParameters, context: Job) + { + super(params, context); + + const settings: IBlenderToolSettings = { + inputMeshFile: params.inputMeshFile, + mode: "screenshot", + timeout: params.timeout + }; + + this.addTool("Blender", settings); + } +} \ No newline at end of file diff --git a/source/server/tasks/ZipTask.ts b/source/server/tasks/ZipTask.ts index 3d14d9f..05b0ad0 100644 --- a/source/server/tasks/ZipTask.ts +++ b/source/server/tasks/ZipTask.ts @@ -38,11 +38,15 @@ export interface IZipTaskParameters extends ITaskParameters inputFile7?: string; inputFile8?: string; /** The type of zip operation we want to do. */ - operation: "zip" | "unzip"; + operation: "zip" | "unzip" | "path-zip"; /** Name to give generated zip file. */ outputFile?: string; /** Degree of compression */ compressionLevel?, + /** Flag to recurse sub-directories */ + recursive?, + /** Filetype to filter for */ + fileFilter?, /** Maximum task execution time in seconds (default: 0, uses timeout defined in tool setup, see [[IToolConfiguration]]). */ timeout?: number; /** Default tool is 7Zip. Specify another tool if needed. */ @@ -71,9 +75,11 @@ export default class ZipTask extends ToolTask inputFile6: { type: "string" }, inputFile7: { type: "string" }, inputFile8: { type: "string" }, - operation: { type: "string", enum: [ "zip", "unzip" ] }, + operation: { type: "string", enum: [ "zip", "unzip", "path-zip" ] }, outputFile: { type: "string", minLength: 1, default: "CookArchive.zip" }, compressionLevel: { type: "integer", minimum: 0, default: 5 }, + fileFilter: { type: "string" }, + recursive: { type: "boolean", default: false }, timeout: { type: "integer", minimum: 0, default: 0 }, tool: { type: "string", enum: [ "SevenZip" ], default: "SevenZip" } }, @@ -101,6 +107,8 @@ export default class ZipTask extends ToolTask inputFile7: params.inputFile7, inputFile8: params.inputFile8, compressionLevel: params.compressionLevel, + recursive: params.recursive, + fileFilter: params.fileFilter, operation: params.operation, outputFile: params.outputFile, timeout: params.timeout diff --git a/source/server/tools/BlenderTool.ts b/source/server/tools/BlenderTool.ts index aed87b9..58e4d54 100644 --- a/source/server/tools/BlenderTool.ts +++ b/source/server/tools/BlenderTool.ts @@ -95,6 +95,9 @@ export default class BlenderTool extends Tool else if(settings.mode === "merge") { operation += ` --python "${instance.getFilePath("../../scripts/BlenderMergeTextures.py")}" -- "${inputFilePath}" "${instance.getFilePath(settings.outputFile2)}" "${instance.getFilePath(settings.outputFile)}"`; } + else if(settings.mode === "screenshot") { + operation += ` --python "${instance.getFilePath("../../scripts/BlenderScreenshot.py")}" -- "${inputFilePath}"`; + } const command = `"${this.configuration.executable}" ${operation}`; diff --git a/source/server/tools/ImageMagickTool.ts b/source/server/tools/ImageMagickTool.ts index 93ba918..7641753 100644 --- a/source/server/tools/ImageMagickTool.ts +++ b/source/server/tools/ImageMagickTool.ts @@ -30,13 +30,15 @@ export interface IImageMagickToolSettings extends IToolSettings /** Name of the image file for the blue channel (optional, only required if combining individual channels). */ blueChannelInputFile?: string; /** Name of the output image file. */ - outputImageFile: string; + outputImageFile?: string; /** The compression quality for JPEG images (0 - 100). */ quality?: number; /** Automatic stretching of the final image. */ normalize?: boolean; /** Gamma correction of the final image (1.0 = unchanged). */ gamma?: number; + /** Clips image to black (value < 128) or white (value > 128) */ + level?: number; /** Resizes the image. values <= 2 represent relative scale, otherwise absolute size in pixels. */ resize?: number; /** If true, expects three input images which are copied to the red, green, and blue channels. */ @@ -45,6 +47,12 @@ export interface IImageMagickToolSettings extends IToolSettings channelNormalize?: boolean; /** Gamma correction of the individual channels (1.0 = unchanged). */ channelGamma?: number[]; + /** Name of the RGB input image folder (for batch conversion). */ + inputImageFolder?: string; + /** Name of the output image folder. (for batch conversion) */ + outputImageFolder?: string; + /** Filetype to batch convert folders of images to. */ + batchConvertType?: string; } export type ImageMagickInstance = ToolInstance; @@ -59,63 +67,93 @@ export default class ImageMagickTool extends Tool { const settings = instance.settings; + let operation = ""; - const outputImagePath = instance.getFilePath(settings.outputImageFile); - if (!outputImagePath) { - throw new Error("ImageMagickTool: missing output map file"); - } + if(settings.inputImageFolder) { // batch conversion + const outputImagePath = instance.getFilePath(settings.outputImageFolder); + if (!outputImagePath) { + throw new Error("ImageMagickTool: missing output map folder"); + } + + const inputImagePath = instance.getFilePath(settings.inputImageFolder); + if (!inputImagePath) { + throw new Error("ImageMagickTool: missing input map folder"); + } + + if (!settings.batchConvertType) { + throw new Error("ImageMagickTool: missing filetype to convert to"); + } - let operation = "convert"; + operation = "mogrify"; - if (settings.channelCombine) { - const redImagePath = instance.getFilePath(settings.redChannelInputFile); - const greenImagePath = instance.getFilePath(settings.greenChannelInputFile); - const blueImagePath = instance.getFilePath(settings.blueChannelInputFile); + let quality = settings.quality || 70; - if (!redImagePath || !greenImagePath || !blueImagePath) { - throw new Error("ImageMagickTool.run - missing input map file"); + if (settings.level) { + const lvl = settings.level; + const pct = (lvl/255*100).toFixed(2); + operation += ` -level ${lvl <= 128 ? pct+"%" : "0,"+pct+"%"}`; } - let channelGamma = [ 1.0, 1.0, 1.0 ]; - if (Array.isArray(settings.channelGamma) && settings.channelGamma.length === 3) { - channelGamma = settings.channelGamma; + operation += ` -path "${outputImagePath}" -quality ${quality} -format ${settings.batchConvertType} "${inputImagePath}\\*.*"`; + } + else { // single image conversion + const outputImagePath = instance.getFilePath(settings.outputImageFile); + if (!outputImagePath) { + throw new Error("ImageMagickTool: missing output map file"); } - const channelAutoLevel = settings.channelNormalize ? "-auto-level" : ""; + operation = "convert"; - operation += [ - ` ( "${redImagePath}" ${channelAutoLevel} -gamma ${channelGamma[0]} )`, - ` ( "${greenImagePath}" ${channelAutoLevel} -gamma ${channelGamma[1]} )`, - ` ( "${blueImagePath}" ${channelAutoLevel} -gamma ${channelGamma[2]} ) -combine`, - ].join(""); - } - else { - const inputImagePath = instance.getFilePath(settings.inputImageFile); - operation += ` "${inputImagePath}"`; - } + if (settings.channelCombine) { + const redImagePath = instance.getFilePath(settings.redChannelInputFile); + const greenImagePath = instance.getFilePath(settings.greenChannelInputFile); + const blueImagePath = instance.getFilePath(settings.blueChannelInputFile); - let resize = settings.resize || 1.0; - if (resize <= 2.0 && resize !== 1.0) { - operation += ` -resize ${Math.round(resize * 100)}%`; - } - else if (resize !== 1.0) { - operation += ` -resize ${Math.round(resize)}`; - } + if (!redImagePath || !greenImagePath || !blueImagePath) { + throw new Error("ImageMagickTool.run - missing input map file"); + } - if (settings.normalize === true) { - operation += " -auto-level"; - } + let channelGamma = [ 1.0, 1.0, 1.0 ]; + if (Array.isArray(settings.channelGamma) && settings.channelGamma.length === 3) { + channelGamma = settings.channelGamma; + } - const gamma = settings.gamma || 1.0; - if (gamma !== 1.0) { - operation += ` -gamma ${gamma}`; - } + const channelAutoLevel = settings.channelNormalize ? "-auto-level" : ""; + + operation += [ + ` ( "${redImagePath}" ${channelAutoLevel} -gamma ${channelGamma[0]} )`, + ` ( "${greenImagePath}" ${channelAutoLevel} -gamma ${channelGamma[1]} )`, + ` ( "${blueImagePath}" ${channelAutoLevel} -gamma ${channelGamma[2]} ) -combine`, + ].join(""); + } + else { + const inputImagePath = instance.getFilePath(settings.inputImageFile); + operation += ` "${inputImagePath}"`; + } + + let resize = settings.resize || 1.0; + if (resize <= 2.0 && resize !== 1.0) { + operation += ` -resize ${Math.round(resize * 100)}%`; + } + else if (resize !== 1.0) { + operation += ` -resize ${Math.round(resize)}`; + } + + if (settings.normalize === true) { + operation += " -auto-level"; + } - let quality = settings.quality || 70; - if (outputImagePath.toLowerCase().endsWith("png")) { - quality = 100; + const gamma = settings.gamma || 1.0; + if (gamma !== 1.0) { + operation += ` -gamma ${gamma}`; + } + + let quality = settings.quality || 70; + if (outputImagePath.toLowerCase().endsWith("png")) { + quality = 100; + } + operation += ` -quality ${quality} "${outputImagePath}"`; } - operation += ` -quality ${quality} "${outputImagePath}"`; const command = `"${this.configuration.executable}" ${operation}`; diff --git a/source/server/tools/MeshlabTool.ts b/source/server/tools/MeshlabTool.ts index 5cab43f..f25b01c 100644 --- a/source/server/tools/MeshlabTool.ts +++ b/source/server/tools/MeshlabTool.ts @@ -58,7 +58,11 @@ export default class MeshlabTool extends Tool "ComputeFaceNormals": { name: "Re-Compute Face Normals" }, "ComputeVertexNormals": { name: "Re-Compute Vertex Normals" }, "SelectSmallComponents": { name: "Select small disconnected component"}, - "DeleteSelected": { name: "Delete Selected Faces and Vertices"} + "DeleteSelected": { name: "Delete Selected Faces and Vertices"}, + "CenterScene": { name: "Transform: Translate, Center, set Origin"}, + "ConditionalFaceSelect": { name: "Conditional Face Selection"}, + "SelectConnectedFaces": { name: "Select Connected Faces" }, + "InvertSelection": { name: "Invert Selection" } /*"MeshReport": { name: "Generate JSON Report", type: "xml" }*/ }; @@ -158,6 +162,19 @@ export default class MeshlabTool extends Tool filterSteps.forEach(filterDef => { const filterType = filterDef.type === "xml" ? "xmlfilter" : "filter"; + if(filterDef.name === "Transform: Translate, Center, set Origin") { + scriptLines.push(``); + scriptLines.push(``); + scriptLines.push(``); + scriptLines.push(``); + scriptLines.push(``); + scriptLines.push(``); + scriptLines.push(``); + scriptLines.push(``); + scriptLines.push(``); + return; + } + if (filter.params) { scriptLines.push(`<${filterType} name="${filterDef.name}">`); for (const paramName in filter.params) { @@ -188,12 +205,16 @@ export default class MeshlabTool extends Tool private getParameter(name: string, value: string | number | boolean, type?: string) { if (typeof value === "string") { - const parsedValue = parseFloat(value) || 0; + const parsedValue = parseFloat(value) || null; if (value.indexOf("%") > -1) { return ``; } + if (parsedValue == null) { + return ``; + } + value = parsedValue; } diff --git a/source/server/tools/MeshroomTool.ts b/source/server/tools/MeshroomTool.ts index 60e9814..9158512 100644 --- a/source/server/tools/MeshroomTool.ts +++ b/source/server/tools/MeshroomTool.ts @@ -23,7 +23,6 @@ export interface IMeshroomToolSettings extends IToolSettings imageInputFolder: string; outputFile?: string; scalebarFile?: string; - generatePointCloud?: boolean; } //////////////////////////////////////////////////////////////////////////////// diff --git a/source/server/tools/MetashapeTool.ts b/source/server/tools/MetashapeTool.ts index 1f3e190..1d09c66 100644 --- a/source/server/tools/MetashapeTool.ts +++ b/source/server/tools/MetashapeTool.ts @@ -23,13 +23,24 @@ import Tool, { IToolMessageEvent, IToolSettings, IToolSetup, ToolInstance } from export interface IMetashapeToolSettings extends IToolSettings { imageInputFolder: string; + alignImageFolder?: string; + maskImageFolder?: string; outputFile: string; mode: string; inputModelFile?: string; camerasFile?: string; scalebarFile?: string; - generatePointCloud?: boolean; optimizeMarkers?: boolean; + alignmentLimit?: number; + tiepointLimit?: number; + keypointLimit?: number; + turntableGroups?: boolean; + depthMaxNeighbors?: number; + genericPreselection?: boolean; + meshQuality?: string; + depthMapQuality?: string; + customFaceCount?: number; + maskMode?: string; } export type MetashapeInstance = ToolInstance; @@ -81,7 +92,45 @@ export default class MetashapeTool extends Tool e == settings.meshQuality); + operation += ` -mq ${qualityIdx} `; + + if(qualityIdx == 3) { + operation += ` -cfc ${settings.customFaceCount} `; + } + } + if(settings.depthMapQuality) { + const opts = [ "Highest", "High", "Medium", "Low" ]; + const qualityIdx = opts.findIndex((e) => e == settings.depthMapQuality); + operation += ` -dmq ${qualityIdx} `; + } } else if(settings.mode === "texture") { const inputModelPath = instance.getFilePath(settings.inputModelFile); diff --git a/source/server/tools/RealityCaptureTool.ts b/source/server/tools/RealityCaptureTool.ts index ce0917d..def1d38 100644 --- a/source/server/tools/RealityCaptureTool.ts +++ b/source/server/tools/RealityCaptureTool.ts @@ -23,7 +23,6 @@ export interface IRealityCaptureToolSettings extends IToolSettings imageInputFolder: string; outputFile?: string; scalebarFile?: string; - generatePointCloud?: boolean; } //////////////////////////////////////////////////////////////////////////////// diff --git a/source/server/tools/SevenZipTool.ts b/source/server/tools/SevenZipTool.ts index a831400..d3016e5 100644 --- a/source/server/tools/SevenZipTool.ts +++ b/source/server/tools/SevenZipTool.ts @@ -32,6 +32,8 @@ export interface ISevenZipToolSettings extends IToolSettings inputFile7: string; inputFile8: string; compressionLevel: number; + fileFilter: string; + recursive: boolean; operation: string; outputFile: string; } @@ -76,6 +78,14 @@ export default class SevenZipTool extends Tool