diff --git a/pyproject.toml b/pyproject.toml index 43aa2f2..a367a3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "foundry-nuke" -version = "0.3.0" +version = "0.4.0" description = "Collection of script & resources for Foundry's Nuke software." authors = ["Liam Collod "] readme = "README.md" diff --git a/src/imageCropDivide/ImageCropDivide.nk b/src/imageCropDivide/ImageCropDivide.nk new file mode 100644 index 0000000..a7468a4 --- /dev/null +++ b/src/imageCropDivide/ImageCropDivide.nk @@ -0,0 +1,50 @@ +Group { + name imageCropDivide + tile_color 0x5c3d84ff + addUserKnob {20 User} + addUserKnob {26 header_step1 l "" T "

Step1: configure

"} + addUserKnob {3 width_max l "Width Max"} + width_max 1920 + addUserKnob {3 height_max l "Height Max" -STARTLINE} + height_max 1080 + addUserKnob {3 width_source l "Width Source"} + width_source {{width}} + addUserKnob {3 height_source l "Height Source" -STARTLINE} + height_source {{height}} + addUserKnob {2 export_directory l "Export Directory" +STARTLINE} + addUserKnob {1 combined_filepath l "Combined File Path" t "without file extension" +STARTLINE} + addUserKnob {26 "" +STARTLINE} + addUserKnob {26 header_step2 l "" T "

Step2: create crop nodes

" +STARTLINE} + addUserKnob {26 spacer1 l "" T " " +STARTLINE} + addUserKnob {22 icd_script l "Copy Setup to ClipBoard" T "\"\"\"\nversion=4\nauthor=Liam Collod\nlast_modified=24/04/2022\npython>2.7\ndependencies=\{\n nuke=*\n\}\n\n[What]\n\nFrom given maximum dimensions, divide an input image into multiples crops.\nThis a combined script of and .\nMust be executed from a python button knob.\n\n[Use]\n\nMust be executed from a python button knob.\n\n[License]\n\nCopyright 2022 Liam Collod\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n\n\"\"\"\n\nimport logging\nimport math\nimport platform\nimport subprocess\nimport sys\n\ntry:\n from typing import Tuple, List\nexcept ImportError:\n pass\n\nimport nuke\n\n\nLOGGER = logging.getLogger(\"\{\}.\{\}\".format(nuke.thisNode(), nuke.thisKnob()))\n\n# dynamically replaced on build\nPASS_NUKE_TEMPLATE = 'Dot \{\\n name Dot_%PASS_ID%_1\\n xpos %PASS_XPOS%\\n ypos 0\\n\}\\nCrop \{\\n name Crop_%PASS_ID%_1\\n xpos %PASS_XPOS%\\n ypos 50\\n box \{%BOX_X% %BOX_Y% %BOX_R% %BOX_T%\}\\n reformat true\\n\}\\nModifyMetaData \{\\n name ModifyMetaData_%PASS_ID%_1\\n xpos %PASS_XPOS%\\n ypos 100\\n metadata \{\{set %METADATA_KEY% %PASS_ID%\}\}\\n\}\\nclone $%WRITE_CLONE_ID% \{\\n xpos %PASS_XPOS%\\n ypos 150\\n\}\\n'\nWRITE_MASTER_NUKE_TEMPLATE = 'Write \{\\n xpos 0\\n ypos -100\\n file \"[value %ICD_NODE%.export_directory]/[metadata %METADATA_KEY%].jpg\"\\n file_type jpeg\\n _jpeg_quality 1\\n _jpeg_sub_sampling 4:4:4\\n\}'\n\n\nclass CropCoordinate:\n \"\"\"\n Dataclass or \"struct\" that just hold multipel attribute represent a crop coordinates.\n \"\"\"\n\n def __init__(self, x_start, y_start, x_end, y_end, width_index, height_index):\n self.x_start = x_start\n self.y_start = y_start\n self.x_end = x_end\n self.y_end = y_end\n self.width_index = width_index\n self.height_index = height_index\n\n\ndef generate_crop_coordinates(width_max, height_max, width_source, height_source):\n \"\"\"\n Split the guven source coordinates area into multiple crops which are all tiles\n of the same size but which can have width!=height.\n\n This implies that the combination of the crop might be better than the source area\n and need to be cropped.\n\n Args:\n width_max (int): maximum allowed width for each crop\n height_max (int): maximum allowed height for each crop\n width_source (int): width of the source to crop\n height_source (int): height of the source to crop\n\n Returns:\n list[CropCoordinate]: list of crops to perform to match the given parameters requested\n \"\"\"\n # ceil to get the biggest number of crops\n width_crops_n = math.ceil(width_source / width_max)\n height_crops_n = math.ceil(height_source / height_max)\n # floor to get maximal crop dimension\n width_crop = math.ceil(width_source / width_crops_n)\n height_crop = math.ceil(height_source / height_crops_n)\n\n if not width_crops_n or not height_crops_n:\n raise RuntimeError(\n \"[generate_crop_coordinates] Can't find a number of crop to perform on r(\{\})\"\n \" or t(\{\}) for the following setup :\\n\"\n \"max=\{\}x\{\} ; source=\{\}x\{\}\".format(\n width_crops_n,\n height_crops_n,\n width_max,\n height_max,\n width_source,\n height_source,\n )\n )\n\n width_crops = []\n\n for i in range(width_crops_n):\n start = width_crop * i\n end = width_crop * i + width_crop\n width_crops.append((start, end))\n\n height_crops = []\n\n for i in range(height_crops_n):\n start = height_crop * i\n end = height_crop * i + height_crop\n height_crops.append((start, end))\n\n # nuke assume 0,0 is bottom left but we want 0,0 to be top-left\n height_crops.reverse()\n\n crops = []\n\n for width_i, width in enumerate(width_crops):\n for height_i, height in enumerate(height_crops):\n crop = CropCoordinate(\n x_start=width[0],\n y_start=height[0],\n x_end=width[1],\n y_end=height[1],\n # XXX: indexes start at 1\n width_index=width_i + 1,\n height_index=height_i + 1,\n )\n crops.append(crop)\n\n # a 2x2 image is indexed like\n # [1 3]\n # [2 4]\n\n return crops\n\n\ndef register_in_clipboard(data):\n \"\"\"\n Args:\n data(str):\n \"\"\"\n\n # Check which operating system is running to get the correct copying keyword.\n if platform.system() == \"Darwin\":\n copy_keyword = \"pbcopy\"\n elif platform.system() == \"Windows\":\n copy_keyword = \"clip\"\n else:\n raise OSError(\"Current os not supported. Only [Darwin, Windows]\")\n\n subprocess.run(copy_keyword, universal_newlines=True, input=data)\n return\n\n\ndef generate_nk(\n width_max,\n height_max,\n width_source,\n height_source,\n node_name,\n):\n \"\"\"\n\n Args:\n width_max(int):\n height_max(int):\n width_source(int):\n height_source(int):\n node_name(str):\n\n Returns:\n str: .nk formatted string representing the nodegraph\n \"\"\"\n\n crop_coordinates = generate_crop_coordinates(\n width_max,\n height_max,\n width_source,\n height_source,\n )\n\n out = \"\"\n\n master_write_id = \"C171d00\"\n pass_metadata_key = \"__crop/pass_id\"\n\n master_write = WRITE_MASTER_NUKE_TEMPLATE.replace(\n \"%METADATA_KEY%\", pass_metadata_key\n )\n master_write = master_write.replace(\"%ICD_NODE%\", node_name)\n out += \"clone node7f6100171d00|Write|21972 \{\}\\n\".format(master_write)\n out += \"set \{\} [stack 0]\\n\".format(master_write_id)\n\n for index, crop_coordinate in enumerate(\n crop_coordinates\n ): # type: int, CropCoordinate\n pass_nk = PASS_NUKE_TEMPLATE\n pass_id = \"\{\}x\{\}\".format(\n crop_coordinate.width_index, crop_coordinate.height_index\n )\n pos_x = 125 * index\n\n pass_nk = pass_nk.replace(\"%PASS_ID%\", str(pass_id))\n pass_nk = pass_nk.replace(\"%PASS_XPOS%\", str(pos_x))\n pass_nk = pass_nk.replace(\"%WRITE_CLONE_ID%\", str(master_write_id))\n pass_nk = pass_nk.replace(\"%METADATA_KEY%\", str(pass_metadata_key))\n pass_nk = pass_nk.replace(\"%BOX_X%\", str(crop_coordinate.x_end))\n pass_nk = pass_nk.replace(\"%BOX_Y%\", str(crop_coordinate.y_end))\n pass_nk = pass_nk.replace(\"%BOX_R%\", str(crop_coordinate.x_start))\n pass_nk = pass_nk.replace(\"%BOX_T%\", str(crop_coordinate.y_start))\n\n out += \"\{\}push $\{\}\\n\".format(pass_nk, master_write_id)\n continue\n\n LOGGER.info(\"[generate_nk] Finished.\")\n return out\n\n\ndef run():\n def _check(variable, name):\n if not variable:\n raise ValueError(\"\{\} can't be False/None/0\".format(name))\n\n LOGGER.info(\"[run] Started.\")\n\n width_max = nuke.thisNode()[\"width_max\"].getValue()\n height_max = nuke.thisNode()[\"height_max\"].getValue()\n width_source = nuke.thisNode()[\"width_source\"].getValue()\n height_source = nuke.thisNode()[\"height_source\"].getValue()\n node_name = nuke.thisNode().name()\n\n _check(width_max, \"width_max\")\n _check(height_max, \"height_max\")\n _check(width_source, \"width_source\")\n _check(height_source, \"height_source\")\n\n nk_str = generate_nk(\n width_max=width_max,\n height_max=height_max,\n width_source=width_source,\n height_source=height_source,\n node_name=node_name,\n )\n register_in_clipboard(nk_str)\n\n LOGGER.info(\"[run] Finished. Nodegraph copied to clipboard.\")\n return\n\n\n# remember: this modifies the root LOGGER only if it never has been before\nlogging.basicConfig(\n level=logging.INFO,\n format=\"%(levelname)-7s | %(asctime)s [%(name)s] %(message)s\",\n stream=sys.stdout,\n)\nrun()\n" -STARTLINE} + addUserKnob {26 info l "" T "press ctrl+v in the nodegraph after clicking the above" +STARTLINE} + addUserKnob {26 "" +STARTLINE} + addUserKnob {26 header_step3 l "" T "

Step3: write

" +STARTLINE} + addUserKnob {26 info_step3 l "" T "- edit the top-most write node as wished\n- unclone all the other write node\n- render all write node to disk" +STARTLINE} + addUserKnob {26 "" +STARTLINE} + addUserKnob {26 header_step4 l "" T "

Step4: combine

" +STARTLINE} + addUserKnob {26 info_step4 l "" T "Combine need external programs, it can work with:\n- oiiotool: path to .exe set in OIIOTOOL env var\n- oiiotool: path to .exe set in below knob\n- Pillow python library set in PYTHONPATH env var" +STARTLINE} + addUserKnob {26 spacer2 l "" T " " +STARTLINE} + addUserKnob {22 combine_script l "Combine From Export Directory" T "import abc\nimport logging\nimport os\nimport subprocess\nimport sys\n\nimport nuke\n\nLOGGER = logging.getLogger(__name__)\n\n\nclass BaseCombineMethod:\n name = \"\"\n\n def __init__(self, *args, **kwargs):\n if not self.name:\n raise NotImplementedError(\"name attribute must be implemented\")\n\n @abc.abstractmethod\n def run(\n self,\n directory,\n combined_filepath,\n delete_crops,\n target_width,\n target_height,\n ):\n \"\"\"\n\n Args:\n directory(str): filesystem path to an existing directory with file inside\n combined_filepath(str): valid filesystem file name without extension\n delete_crops(bool): True to delete crops once combined\n target_width(int): taregt width of the combined image\n target_height(int): taregt height of the combined image\n\n Returns:\n str: filesystem path to the combined file created\n \"\"\"\n pass\n\n\ndef find_crop_images_in_dir(directory):\n \"\"\"\n Args:\n directory(str): filesystem path to an existing directory with file inside\n\n Returns:\n list[str]: list of existing files\n \"\"\"\n # XXX: we assume directory only contains the images we want to combine but\n # we still perform some sanity checks just in case\n src_files = [\n os.path.join(directory, filename) for filename in os.listdir(directory)\n ]\n src_ext = os.path.splitext(src_files[0])[1]\n src_files = [\n filepath\n for filepath in src_files\n if os.path.isfile(filepath) and filepath.endswith(src_ext)\n ]\n return src_files\n\n\ndef sort_crops_paths_topleft_rowcolumn(crop_paths):\n \"\"\"\n Change the order of the given list of images so it correspond to a list of crop\n starting from the top-left, doing rows then columns.\n\n Example for a 2x3 image::\n\n [1 2]\n [3 4]\n [5 6]\n\n Args:\n crop_paths: list of file paths exported by the ICD node.\n\n Returns:\n new list of same file paths but sorted differently.\n \"\"\"\n\n # copy\n _crop_paths = list(crop_paths)\n _crop_paths.sort()\n\n _, mosaic_max_height = get_grid_size(crop_paths)\n\n # for a 2x3 image we need to convert like :\n # [1 4] > [1 2]\n # [2 5] > [3 4]\n # [3 6] > [5 6]\n buffer = []\n for row_index in range(mosaic_max_height):\n buffer += _crop_paths[row_index::mosaic_max_height]\n\n return buffer\n\n\ndef get_grid_size(crop_paths):\n \"\"\"\n Returns:\n tuple[int, int]: (columns number, rows number).\n \"\"\"\n # copy\n _crop_paths = list(crop_paths)\n _crop_paths.sort()\n # name of a file is like \"0x2.jpg\"\n mosaic_max = os.path.splitext(os.path.basename(_crop_paths[-1]))[0]\n mosaic_max_width = int(mosaic_max.split(\"x\")[0])\n mosaic_max_height = int(mosaic_max.split(\"x\")[1])\n return mosaic_max_width, mosaic_max_height\n\n\nclass OiiotoolCombineMethod(BaseCombineMethod):\n name = \"oiiotool executable\"\n\n def __init__(self, oiiotool_path=None, *args, **kwargs):\n super(OiiotoolCombineMethod, self).__init__()\n if oiiotool_path:\n self._oiiotool_path = oiiotool_path\n else:\n self._oiiotool_path = os.getenv(\"OIIOTOOL\")\n\n if not self._oiiotool_path:\n raise ValueError(\"No oiiotool path found.\")\n if not os.path.exists(self._oiiotool_path):\n raise ValueError(\n \"Oiiotool path provide doesn't exist: \{\}\".format(oiiotool_path)\n )\n\n def run(\n self,\n directory,\n combined_filepath,\n delete_crops,\n target_width,\n target_height,\n ):\n src_files = find_crop_images_in_dir(directory)\n src_ext = os.path.splitext(src_files[0])[1]\n if not src_files:\n raise ValueError(\n \"Cannot find crops files to combine in \{\}\".format(directory)\n )\n\n dst_file = combined_filepath + src_ext\n\n src_files = sort_crops_paths_topleft_rowcolumn(src_files)\n tiles_size = get_grid_size(src_files)\n\n command = [self._oiiotool_path]\n command += src_files\n # https://openimageio.readthedocs.io/en/latest/oiiotool.html#cmdoption-mosaic\n # XXX: needed so hack explained under works\n command += [\"--metamerge\"]\n command += [\"--mosaic\", \"\{\}x\{\}\".format(tiles_size[0], tiles_size[1])]\n command += [\"--cut\", \"0,0,\{\},\{\}\".format(target_width - 1, target_height - 1)]\n # XXX: hack to preserve metadata that is lost with the mosaic operation\n command += [\"-i\", src_files[0], \"--chappend\"]\n command += [\"-o\", dst_file]\n\n LOGGER.info(\"about to call oiiotool with \{\}\".format(command))\n subprocess.check_call(command)\n\n if not os.path.exists(dst_file):\n raise RuntimeError(\n \"Unexpected issue: combined file doesn't exist on disk at <\{\}>\"\n \"\".format(dst_file)\n )\n\n if delete_crops:\n for src_file in src_files:\n os.unlink(src_file)\n\n return dst_file\n\n\nclass PillowCombineMethod(BaseCombineMethod):\n name = \"python Pillow library\"\n\n def __init__(self, *args, **kwargs):\n super(PillowCombineMethod, self).__init__()\n # expected to raise if PIL not available\n from PIL import Image\n\n def run(\n self,\n directory,\n combined_filepath,\n delete_crops,\n target_width,\n target_height,\n ):\n from PIL import Image\n\n src_files = find_crop_images_in_dir(directory)\n src_files = sort_crops_paths_topleft_rowcolumn(src_files)\n column_number, row_number = get_grid_size(src_files)\n\n src_ext = os.path.splitext(src_files[0])[1]\n dst_file = combined_filepath + src_ext\n\n images = [Image.open(filepath) for filepath in src_files]\n # XXX: assume all crops have the same size\n tile_size = images[0].size\n\n # XXX: we use an existing image for our new image so we preserve metadata\n combined_image = Image.open(src_files[0])\n buffer_image = Image.new(\n mode=combined_image.mode, size=(target_width, target_height)\n )\n # XXX: part of the hack to preserve metadata, we do that because image.resize sucks\n # and doesn't return an exact copy of the initial instance\n combined_image.im = buffer_image.im\n combined_image._size = buffer_image._size\n image_index = 0\n\n for column_index in range(column_number):\n for row_index in range(row_number):\n image = images[image_index]\n image_index += 1\n coordinates = (tile_size[0] * row_index, tile_size[1] * column_index)\n combined_image.paste(image, box=coordinates)\n\n save_kwargs = \{\}\n if src_ext.startswith(\".jpg\"):\n save_kwargs = \{\n \"quality\": \"keep\",\n \"subsampling\": \"keep\",\n \"qtables\": \"keep\",\n \}\n\n combined_image.save(fp=dst_file, **save_kwargs)\n\n if delete_crops:\n for src_file in src_files:\n os.unlink(src_file)\n\n return dst_file\n\n\nCOMBINE_METHODS = [\n OiiotoolCombineMethod,\n PillowCombineMethod,\n]\n\n\ndef run():\n LOGGER.info(\"[run] Started.\")\n\n export_dir = nuke.thisNode()[\"export_directory\"].evaluate() # type: str\n combined_filepath = nuke.thisNode()[\"combined_filepath\"].evaluate() # type: str\n delete_crops = nuke.thisNode()[\"delete_crops\"].getValue() # type: bool\n oiiotool_path = nuke.thisNode()[\"oiiotool_path\"].evaluate() # type: str\n width_source = int(nuke.thisNode()[\"width_source\"].getValue()) # type: int\n height_source = int(nuke.thisNode()[\"height_source\"].getValue()) # type: int\n\n if not export_dir or not os.path.isdir(export_dir):\n raise ValueError(\n \"Invalid export directory <\{\}>: not found on disk.\".format(export_dir)\n )\n\n combine_instance = None\n\n for combine_method_class in COMBINE_METHODS:\n try:\n combine_instance = combine_method_class(oiiotool_path=oiiotool_path)\n except Exception as error:\n LOGGER.debug(\"skipping class \{\}: \{\}\".format(combine_method_class, error))\n\n if not combine_instance:\n raise RuntimeError(\n \"No available method to combine the renders found. Available methods are:\\n\{\}\"\n \"\\nSee documentation for details.\"\n \"\".format([method.name for method in COMBINE_METHODS])\n )\n\n LOGGER.info(\"[run] about to combine directory \{\} ...\".format(export_dir))\n combined_filepath = combine_instance.run(\n directory=export_dir,\n delete_crops=delete_crops,\n combined_filepath=combined_filepath,\n target_width=width_source,\n target_height=height_source,\n )\n nuke.message(\"Successfully created combine file: \{\}\".format(combined_filepath))\n LOGGER.info(\"[run] Finished.\")\n\n\n# remember: this modifies the root LOGGER only if it never has been before\nlogging.basicConfig(\n level=logging.INFO,\n format=\"%(levelname)-7s | %(asctime)s [%(name)s] %(message)s\",\n stream=sys.stdout,\n)\nrun()\n" -STARTLINE} + addUserKnob {26 header_combine l " " T "

options:

" +STARTLINE} + addUserKnob {6 delete_crops l "Delete Crops" t "Delete crops files created once the combined image is finished." +STARTLINE} + delete_crops true + addUserKnob {2 oiiotool_path l "oiiotool path" +STARTLINE} + addUserKnob {20 About} + addUserKnob {26 toolName l name T ImageCropDivide} + addUserKnob {26 toolVersion l version T 1.1.0} + addUserKnob {26 toolAuthor l author T "Liam Collod"} + addUserKnob {26 toolDescription l description T "Crop an image into tiles to be written on disk, and recombine the tiles to a single image."} + addUserKnob {26 toolUrl l url T "https://github.com/MrLixm/Foundry_Nuke"} +} + Input { + inputs 0 + name Input1 + xpos 0 + } + Output { + name Output1 + xpos 0 + ypos 300 + } +end_group diff --git a/src/imageCropDivide/README.md b/src/imageCropDivide/README.md index 1126c6d..4681b74 100644 --- a/src/imageCropDivide/README.md +++ b/src/imageCropDivide/README.md @@ -2,33 +2,104 @@ ![Python](https://img.shields.io/badge/Python-2.7+-4f4f4f?labelColor=3776ab&logo=python&logoColor=FED142) -From given maximum dimensions, divide an input image into multiples crops. +Divide an image into multiple crops so it can be recombined later. This has some +usefulness in the context of some nuke versions ... ![screenshot of the nodegraph in nuke](doc/img/nodegraph-cover.png) -Compatible with: -- Nuke Non-Commercial -- Python > 2.7 -- Windows, Mac +# requires -# Use +- Nuke or Nuke Non-Commercial +- Nuke with Python >= 2.7 +- All OS should be supported but was only tested on Windows. -The most straightforward setup is to paste/insert the [node.nk](node.nk) node -into your nuke script, setup it and execute the script button. +The combine feature requires `oiiotool` or `Pillow` to be availble on the +system. See below for more information. + +# Usage + +- Copy/paste the content of [ImageCropDivide.nk](ImageCropDivide.nk) in Nuke. ![screenshot of the node in nuke](doc/img/node-img.png) -- Set the desired max dimensions and your source size. +- Set the desired max dimensions and your source size if the defaults are not expected. +- `Export Directory`: Set where the directory where the crops must be exported +- `Combined File Name`: if you use the combine feature, name without the extension, of the combined file. - Click the `Copy ...` button - press ctrl+v to paste the node setup in the nodegraph. -- On any of the Write node, modify the settings for export. +- connect the top write node to the same node as the imageCropDivide node +- On any of the cloned Write node, modify the settings for export as desired. - Unclone all the Write nodes `(Alt + shift + K)` +- Render all the Write nodes +- Once finished you have the option to combine the exported crops. See below +for details. + +> [!WARNING] +> it seems that changing the file type of cloned write node make nuke crash +> so you might have to unclone first and propagate changes on all write nodes :/ + +# combining + +Combining will find ALL images in the `Export Directory` and combine them to +a single image named using `Combined File Name` knob value. + +> [!WARNING] +> make sure the `Export Directory` doesn't contain anything else than +> the crop you just exported before combining. + +Combining require external softwares. Different options are available and +automatically detected. + +Here are the option in their order of priority : + +## oiiotool + +Recommended option. Use the `oiiotool.exe` CLI from OpenImageIO to combine back the crops. + +You can specify the path to the oiiotool executable in 2 ways : +- environment variable `OIIOTOOL` +- the `oiiotool path` knob on the node + +> [!TIP] +> You can find it in the Arnold render-engine installation like +> `C:\Program Files\Autodesk\Arnold\maya{VERSION}\bin` +> +> Or alternatively [get it from here](https://www.patreon.com/posts/openimageio-oiio-63609827) (has more plugin support) + + +## Pillow + +A python library for image processing. It work for simple case but suck +for anything else (anything not 8bit). + +It simply requires Pillow to be importable in the python context (`from PIL import Image`). + + +# faq + +> Why is there a ModifyMetadata node ? + +This is the only way I found for now to have a different suffix per Write +node and have them cloned at start. I could remove them and set directly the +suffix on each unique Write node but then it would be a pain to modify one setting +on all the write nodes. + +# Developer + +Instructions to develop the node. + +[ImageCropDivide.nk](ImageCropDivide.nk) is the result of a build process defined +in [src/](src). + +It consist of combining nk file templates (because they have variables) with +python scripts to form a single `.nk` node. + +To build the node simply execute [build.py](src/build.py) from any python 3 +interpreter. + +File suffixed with `-template` contained variable replaced during build. +Variables have the syntax `%VARIABLE_NAME%`. -_Why is there a ModifyMetadata node ?_ -> This is the only way I found for now to have a different suffix per Write -> node and have them cloned at start. I could remove them and set directly the -> suffix on each unique Write node but then it would be a pain to modify one setting -> on all the write nodes. # Licensing diff --git a/src/imageCropDivide/button.py b/src/imageCropDivide/button.py deleted file mode 100644 index 35d1528..0000000 --- a/src/imageCropDivide/button.py +++ /dev/null @@ -1,387 +0,0 @@ -""" -version=3 -author=Liam Collod -last_modified=24/04/2022 -python>2.7 -dependencies={ - nuke=* -} - -[What] - -From given maximum dimensions, divide an input image into multiples crops. -This a combined script of and . -Must be executed from a python button knob. - -[Use] - -Must be executed from a python button knob. - -[License] - -Copyright 2022 Liam Collod -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 logging -import math -import platform -import subprocess -import sys - -try: - from typing import Tuple, List -except: - pass - -import nuke - - -def setup_logging(name, level): - logger = logging.getLogger(name) - logger.setLevel(level) - - if not logger.handlers: - # create a file handler - handler = logging.StreamHandler(stream=sys.stdout) - handler.setLevel(logging.DEBUG) - # create a logging format - formatter = logging.Formatter( - '%(asctime)s - [%(levelname)7s] %(name)30s // %(message)s', - datefmt='%H:%M:%S' - ) - handler.setFormatter(formatter) - # add the file handler to the logger - logger.addHandler(handler) - - return logger - - -logger = setup_logging("imageCropDivide.button", logging.DEBUG) - -PASS_METADATA_PATH = "_crop/passName" -"Metadata key name. Used in write nodes for a flexible pass setup." - - -class CropNode: - """ - When creating an instance no node is created in the Nuke nodegraph. Call update() - to create the node and access it. - - Args: - x_start(int): - x_end(int): - y_start(int): - y_end(int): - identifier(str): allow to identify a crop among an array of crop. - Must be safe to use in a nuke node name - reformat(bool): enable the reformat option on the crop node if true - """ - - def __init__(self, x_start, x_end, y_start, y_end, identifier, reformat=False): - - self.identifier = identifier - self.x_start = x_start - self.x_end = x_end - self.y_start = y_start - self.y_end = y_end - self.reformat = reformat - - self.node = None - - return - - def __repr__(self): - return "{} : x[{} -> {}] - y[{} -> {}] //// xy[{}, {}] rt[{}, {}]".format( - super(CropNode, self).__repr__(), - round(self.x_start, 3), round(self.x_end, 3), - round(self.y_start, 3), round(self.y_end, 3), - round(self.x, 3), round(self.y, 3), - round(self.r, 3), round(self.t, 3) - ) - - def __str__(self): - """ - Returns: - str: node formatted as .nk format - """ - out = "Crop {\n" - out += " box {{{} {} {} {}}}\n".format(self.x, self.y, self.r, self.t) - out += " reformat {}\n".format(str(self.reformat).lower()) - out += "}\n" - return out - - @property - def r(self): - return self.x_start - - @property - def t(self): - return self.y_start - - @property - def x(self): - return self.x_end - - @property - def y(self): - return self.y_end - - def set_name(self, name): - """ - Args: - name(str): - """ - self.node.setName(name) - return - - def update(self): - """ - Update the Crop Nuke node knobs with the values stored in the class instance. - If the node was never created yet, it is created. - """ - - if self.node is None: - self.__update = False - self.node = nuke.createNode("Crop") - self.__update = True - assert self.node, "[CropNode][update] Can't create nuke node {}".format(self) - - self.node["box"].setX(self.x) - self.node["box"].setY(self.y) - self.node["box"].setR(self.r) - self.node["box"].setT(self.t) - self.node["reformat"].setValue(self.reformat) - - return - - -class CropGenerator: - """ - - Args: - max_size(Tuple[int, int]): (r, t) - source_size(Tuple[int, int]): (r, t) - - Attributes: - width_max: - height_max: - width_source: - height_source: - crops: ordered list of CropNode instance created - """ - - def __init__(self, max_size, source_size): - - self.width_max = max_size[0] # type: int - self.height_max = max_size[1] # type: int - self.width_source = source_size[0] # type: int - self.height_source = source_size[1] # type: int - - self.crops = list() # type: List[CropNode] - - self._generate_crops() - - return - - def _get_crop_coordinates(self, crop_number, x=True): - """ - Return a list of ``x`` or ``y`` start/end coordinates for the number of crops - specified. - - Args: - crop_number(int): - x(bool): return ``x`` coordinates if true else ``y`` - - Returns: - Tuple[Tuple[int, int]]: where ((start, end), ...) - """ - - out = list() - c = self.width_source if x else self.height_source - - for i in range(crop_number): - start = c / crop_number * i - end = c / crop_number * i + (c / crop_number) - out.append((start, end)) - - return tuple(out) - - def _generate_crops(self): - """ - Create the CropNode instance stored in attribute. - These instance still doesn't exist in the Nuke nodegraph. - """ - - width_crops_n = math.ceil(self.width_source / self.width_max) - height_crops_n = math.ceil(self.height_source / self.height_max) - - if not width_crops_n or not height_crops_n: - raise RuntimeError( - "[_generate_crops] Can't find a number of crop to perform on r({})" - " or t({}) for the following setup :\n" - "max={}x{} ; source={}x{}".format( - width_crops_n, height_crops_n, self.width_max, self.height_max, - self.width_source, self.height_source - ) - ) - - width_crops = self._get_crop_coordinates(width_crops_n, x=True) - height_crops = self._get_crop_coordinates(height_crops_n, x=False) - - for width_i in range(len(width_crops)): - - for height_i in range(len(height_crops)): - - crop = CropNode( - x_start=width_crops[width_i][0], - y_start=height_crops[height_i][0], - x_end=width_crops[width_i][1], - y_end=height_crops[height_i][1], - identifier="{}x{}".format(width_i, height_i) - ) - self.crops.append(crop) - logger.debug( - "[CropGenerator][_generate_crops] created {}".format(crop.__repr__()) - ) - - continue - - return - - -def register_in_clipboard(data): - """ - Args: - data(str): - """ - - # Check which operating system is running to get the correct copying keyword. - if platform.system() == 'Darwin': - copy_keyword = 'pbcopy' - elif platform.system() == 'Windows': - copy_keyword = 'clip' - else: - raise OSError("Current os not supported. Only [Darwin, Windows]") - - subprocess.run(copy_keyword, universal_newlines=True, input=data) - return - - -def generate_nk( - width_max, - height_max, - width_source, - height_source, -): - """ - - Args: - width_max(int): - height_max(int): - width_source(int): - height_source(int): - - Returns: - str: .nk formatted string representing the nodegraph - """ - - cg = CropGenerator( - (width_max, height_max), - (width_source, height_source), - ) - - out = str() - out += """set cut_paste_input [stack 0] -version 13.1 v3 -push $cut_paste_input\n""" - - id_write_master = None - - for i, cropnode in enumerate(cg.crops): - - pos_x = 125 * i - pos_y = 125 - - out += "Dot {{\n xpos {}\n ypos {}\n}}\n".format(pos_x, pos_y) - id_last = "N173200" - out += "set {} [stack 0]\n".format(id_last) - pos_y += 125 - - # CROPNODE - cropnode.reformat = True - str_cropnode = str(cropnode)[:-2] # remove the 2 last character "}\n" - str_cropnode += " name Crop_{}_\n".format(cropnode.identifier) - str_cropnode += " xpos {}\n ypos {}\n".format(pos_x, pos_y) - str_cropnode += "}\n" - out += str_cropnode - pos_y += 125 - - # ModifyMetadata node - out += "ModifyMetaData {\n" - out += " metadata {{{{set {} {}}}}}\n".format(PASS_METADATA_PATH, - cropnode.identifier) - out += " xpos {}\n ypos {}\n".format(pos_x, pos_y) - out += "}\n" - pos_y += 125 - - # Write node cloning system - if id_write_master: - out += "clone ${} {{\n xpos {}\n ypos {}\n}}\n".format(id_write_master, - pos_x, pos_y) - pos_y += 125 - else: - id_write_master = "C171d00" - out += "clone node7f6100171d00|Write|21972 Write {\n" - out += " xpos {}\n ypos {}\n".format(pos_x, pos_y) - out += " file \"[metadata {}].jpg\"".format(PASS_METADATA_PATH) - out += " file_type jpeg\n _jpeg_quality 1\n _jpeg_sub_sampling 4:4:4\n" - out += "}\n" - out += "set {} [stack 0]\n".format(id_write_master) - - out += "push ${}\n".format(id_last) - continue - - logger.info("[generate_nk] Finished.") - return out - - -def run(): - """ - """ - logger.info("[run] Started.") - - width_max = nuke.thisNode()["width_max"].getValue() - height_max = nuke.thisNode()["height_max"].getValue() - width_source = nuke.thisNode()["width_source"].getValue() - height_source = nuke.thisNode()["height_source"].getValue() - - assert width_max, "ValueError: width_max can't be False/None/0" - assert height_max, "ValueError: height_max can't be False/None/0" - assert width_source, "ValueError: width_source can't be False/None/0" - assert height_source, "ValueError: height_source can't be False/None/0" - - nk_str = generate_nk( - width_max=width_max, - height_max=height_max, - width_source=width_source, - height_source=height_source, - ) - register_in_clipboard(nk_str) - - logger.info("[run] Finished. Nodegraph copied to clipboard.") - return - - -run() \ No newline at end of file diff --git a/src/imageCropDivide/cropAndWrite.py b/src/imageCropDivide/cropAndWrite.py deleted file mode 100644 index dd0a0ac..0000000 --- a/src/imageCropDivide/cropAndWrite.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -version=2 -author=Liam Collod -last_modified=24/04/2022 -python>2.7 -dependencies={ - imageCropDivide=* -} - -[What] - -From given maximum dimensions, divide an input image into multiples crops. -Each crop "pass" have a write setup created. - -[Use] - -... - -[License] - -Copyright 2022 Liam Collod -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 logging -import os.path -import sys - -from imageCropDivide import CropGenerator - - -def setup_logging(name, level): - logger = logging.getLogger(name) - logger.setLevel(level) - - if not logger.handlers: - # create a file handler - handler = logging.StreamHandler(stream=sys.stdout) - handler.setLevel(logging.DEBUG) - # create a logging format - formatter = logging.Formatter( - '%(asctime)s - [%(levelname)7s] %(name)30s // %(message)s', - datefmt='%H:%M:%S' - ) - handler.setFormatter(formatter) - # add the file handler to the logger - logger.addHandler(handler) - - return logger - - -logger = setup_logging("cropAndWrite", logging.DEBUG) - -PASS_METADATA_PATH = "_crop/passName" -"Metadata key name. Used in write nodes for a flexible pass setup." - - -def run( - width_max, - height_max, - width_source, - height_source, - write_nk=False -): - """ - - Args: - width_max(int): - height_max(int): - width_source(int): - height_source(int): - write_nk(bool): True to write a .nk file of the created nodegraph - - Returns: - str: .nk formatted string representing the nodegraph - """ - - cg = CropGenerator( - (width_max, height_max), - (width_source, height_source), - ) - - out = str() - out += """set cut_paste_input [stack 0] -version 13.1 v3 -push $cut_paste_input\n""" - - id_write_master = None - - for i, cropnode in enumerate(cg.crops): - - pos_x = 125 * i - pos_y = 125 - - out += "Dot {{\n xpos {}\n ypos {}\n}}\n".format(pos_x, pos_y) - id_last = "N173200" - out += "set {} [stack 0]\n".format(id_last) - pos_y += 125 - - # CROPNODE - cropnode.reformat = True - str_cropnode = str(cropnode)[:-2] # remove the 2 last character "}\n" - str_cropnode += " name Crop_{}_\n".format(cropnode.identifier) - str_cropnode += " xpos {}\n ypos {}\n".format(pos_x, pos_y) - str_cropnode += "}\n" - out += str_cropnode - pos_y += 125 - - # ModifyMetadata node - out += "ModifyMetaData {\n" - out += " metadata {{{{set {} {}}}}}\n".format(PASS_METADATA_PATH, - cropnode.identifier) - out += " xpos {}\n ypos {}\n".format(pos_x, pos_y) - out += "}\n" - pos_y += 125 - - # Write node cloning system - if id_write_master: - out += "clone ${} {{\n xpos {}\n ypos {}\n}}\n".format(id_write_master, - pos_x, pos_y) - pos_y += 125 - else: - id_write_master = "C171d00" - out += "clone node7f6100171d00|Write|21972 Write {\n" - out += " xpos {}\n ypos {}\n".format(pos_x, pos_y) - out += " file \"[metadata {}].jpg\"".format(PASS_METADATA_PATH) - out += " file_type jpeg\n _jpeg_quality 1\n _jpeg_sub_sampling 4:4:4\n" - out += "}\n" - out += "set {} [stack 0]\n".format(id_write_master) - - out += "push ${}\n".format(id_last) - continue - - if write_nk: - write_nk_file(".", "nodegraph", data=out) - - logger.info("[run] Finished.") - return out - - -def write_nk_file(target_dir, target_name, data): - """ - - Args: - target_dir(str): must be in an existing directory - target_name(str): name of the file without the extension - data(str): - - Returns: - str: path the file has been written to. - """ - target_path = os.path.join(target_dir, "{}.nk".format(target_name)) - with open(target_path, "w") as f: - f.write(data) - logger.info("[write_nk_file] Finished. Wrote {}.".format(target_path)) - - return target_path - - -if __name__ == '__main__': - - run( - width_max=1920, - height_max=1080, - width_source=3580, - height_source=2560, - write_nk=True - ) diff --git a/src/imageCropDivide/dev/generate_nodenk.py b/src/imageCropDivide/dev/generate_nodenk.py deleted file mode 100644 index c0cba67..0000000 --- a/src/imageCropDivide/dev/generate_nodenk.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -python>3 -""" -import os.path -import re -from pathlib import Path - -VERSION = 7 - -BASE = r""" -set cut_paste_input [stack 0] -version 12.2 v5 -push $cut_paste_input -Group { - name imageCropDivide - tile_color 0x5c3d84ff - note_font_size 25 - note_font_color 0xffffffff - selected true - xpos 411 - ypos -125 - addUserKnob {20 User} - addUserKnob {3 width_max} - addUserKnob {3 height_max -STARTLINE} - addUserKnob {3 width_source} - addUserKnob {3 height_source -STARTLINE} - addUserKnob {26 "" +STARTLINE} - addUserKnob {22 icd_script l "Copy Setup to ClipBoard" T "$SCRIPT$" +STARTLINE} - addUserKnob {26 info l " " T "press ctrl+v in the nodegraph after clicking the above button"} - addUserKnob {20 Info} - addUserKnob {26 infotext l "" +STARTLINE T "2022 - Liam Collod
Visit the GitHub repo "} - addUserKnob {26 "" +STARTLINE} - addUserKnob {26 versiontext l "" T "version $VERSION$"} -} - Input { - inputs 0 - name Input1 - xpos 0 - } - Output { - name Output1 - xpos 0 - ypos 300 - } -end_group -""" - -MODULE_BUTTON_PATH = Path("..") / "button.py" -NODENK_PATH = Path("..") / "node.nk" - - -def increment_version(): - - this = Path(__file__) - this_code = this.read_text(encoding="utf-8") - - version = re.search(r"VERSION\s*=\s*(\d+)", this_code) - assert version, f"Can't find in <{this}> !" - new_version = int(version.group(1)) + 1 - new_code = f"VERSION = {new_version}" - new_code = this_code.replace(version.group(0), str(new_code)) - this.write_text(new_code, encoding="utf-8") - - print(f"[{__name__}][increment_version] Incremented {this} to {new_version}.") - return - - -def run(): - - increment_version() - - btnscript = MODULE_BUTTON_PATH.read_text(encoding="utf-8") - - # sanitize for nuke - btnscript = btnscript.replace("\\", r'\\') - btnscript = btnscript.split("\n") - btnscript = r"\n".join(btnscript) - btnscript = btnscript.replace("\"", r'\"') - btnscript = btnscript.replace("{", r'\{') - btnscript = btnscript.replace("}", r'\}') - - node_content = BASE.replace("$SCRIPT$", btnscript) - node_content = node_content.replace("$VERSION$", str(VERSION+1)) - - NODENK_PATH.write_text(node_content, encoding="utf-8") - print(f"[{__name__}][run] node.nk file written to {NODENK_PATH}") - - print(f"[{__name__}][run] Finished.") - return - - -if __name__ == '__main__': - # print(__file__) - run() diff --git a/src/imageCropDivide/doc/img/node-img.png b/src/imageCropDivide/doc/img/node-img.png index ebcdf00..cb77c31 100644 Binary files a/src/imageCropDivide/doc/img/node-img.png and b/src/imageCropDivide/doc/img/node-img.png differ diff --git a/src/imageCropDivide/doc/img/nodegraph-cover.png b/src/imageCropDivide/doc/img/nodegraph-cover.png index d18dec4..8a8e2fa 100644 Binary files a/src/imageCropDivide/doc/img/nodegraph-cover.png and b/src/imageCropDivide/doc/img/nodegraph-cover.png differ diff --git a/src/imageCropDivide/imageCropDivide.py b/src/imageCropDivide/imageCropDivide.py deleted file mode 100644 index 05bc976..0000000 --- a/src/imageCropDivide/imageCropDivide.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -version=7 -author=Liam Collod -last_modified=24/04/2022 -python>2.7 -dependencies={ - nuke=* -} - -[What] - -From given maximum dimensions, divide an input image into multiples crops. - -[Use] - -... - -[License] - -Copyright 2022 Liam Collod -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 logging -import math - -try: - from typing import Tuple, List -except ImportError: - pass - -try: - import nuke -except ImportError: - nuke = None - - -logger = logging.getLogger("imageCropDivide") - - -class CropNode: - """ - When creating an instance no node is created in the Nuke nodegraph. Call update() - to create the node and access it. - - Args: - x_start(int): - x_end(int): - y_start(int): - y_end(int): - identifier(str): allow to identify a crop among an array of crop. - Must be safe to use in a nuke node name - reformat(bool): enable the reformat option on the crop node if true - """ - - def __init__(self, x_start, x_end, y_start, y_end, identifier, reformat=False): - self.identifier = identifier - self.x_start = x_start - self.x_end = x_end - self.y_start = y_start - self.y_end = y_end - self.reformat = reformat - - self.node = None - - return - - def __repr__(self): - return "{} : x[{} -> {}] - y[{} -> {}] //// xy[{}, {}] rt[{}, {}]".format( - super(CropNode, self).__repr__(), - round(self.x_start, 3), - round(self.x_end, 3), - round(self.y_start, 3), - round(self.y_end, 3), - round(self.x, 3), - round(self.y, 3), - round(self.r, 3), - round(self.t, 3), - ) - - def __str__(self): - """ - Returns: - str: node formatted as .nk format - """ - out = "Crop {\n" - out += " box {{{} {} {} {}}}\n".format(self.x, self.y, self.r, self.t) - out += " reformat {}\n".format(str(self.reformat).lower()) - out += "}\n" - return out - - @property - def r(self): - return self.x_start - - @property - def t(self): - return self.y_start - - @property - def x(self): - return self.x_end - - @property - def y(self): - return self.y_end - - def set_name(self, name): - """ - Args: - name(str): - """ - self.node.setName(name) - return - - def update(self): - """ - Update the Crop Nuke node knobs with the values stored in the class instance. - If the node was never created yet, it is created. - """ - - if self.node is None: - self.__update = False - self.node = nuke.createNode("Crop") - self.__update = True - assert self.node, "[CropNode][update] Can't create nuke node {}".format( - self - ) - - self.node["box"].setX(self.x) - self.node["box"].setY(self.y) - self.node["box"].setR(self.r) - self.node["box"].setT(self.t) - self.node["reformat"].setValue(self.reformat) - - return - - -class CropGenerator: - """ - - Args: - max_size(Tuple[int, int]): (r, t) - source_size(Tuple[int, int]): (r, t) - - Attributes: - width_max: - height_max: - width_source: - height_source: - crops: ordered list of CropNode instance created - """ - - def __init__(self, max_size, source_size): - self.width_max = max_size[0] # type: int - self.height_max = max_size[1] # type: int - self.width_source = source_size[0] # type: int - self.height_source = source_size[1] # type: int - - self.crops = list() # type: List[CropNode] - - self._generate_crops() - - return - - def _get_crop_coordinates(self, crop_number, x=True): - """ - Return a list of ``x`` or ``y`` start/end coordinates for the number of crops - specified. - - Args: - crop_number(int): - x(bool): return ``x`` coordinates if true else ``y`` - - Returns: - Tuple[Tuple[int, int]]: where ((start, end), ...) - """ - - out = list() - c = self.width_source if x else self.height_source - - for i in range(crop_number): - start = c / crop_number * i - end = c / crop_number * i + (c / crop_number) - out.append((start, end)) - - return tuple(out) - - def _generate_crops(self): - """ - Create the CropNode instance stored in attribute. - These instance still doesn't exist in the Nuke nodegraph. - """ - - width_crops_n = math.ceil(self.width_source / self.width_max) - height_crops_n = math.ceil(self.height_source / self.height_max) - - if not width_crops_n or not height_crops_n: - raise RuntimeError( - "[_generate_crops] Can't find a number of crop to perform on r({})" - " or t({}) for the following setup :\n" - "max={}x{} ; source={}x{}".format( - width_crops_n, - height_crops_n, - self.width_max, - self.height_max, - self.width_source, - self.height_source, - ) - ) - - width_crops = self._get_crop_coordinates(width_crops_n, x=True) - height_crops = self._get_crop_coordinates(height_crops_n, x=False) - - for width_i in range(len(width_crops)): - for height_i in range(len(height_crops)): - crop = CropNode( - x_start=width_crops[width_i][0], - y_start=height_crops[height_i][0], - x_end=width_crops[width_i][1], - y_end=height_crops[height_i][1], - identifier="{}x{}".format(width_i, height_i), - ) - self.crops.append(crop) - logger.debug( - "[CropGenerator][_generate_crops] created {}".format( - crop.__repr__() - ) - ) - - continue - - return - - -def test(): - """ - For testing out of a Nuke context - """ - crop_nodes = [] - - cg = CropGenerator((1920, 1080), (3872, 2592)) - for cropnode in cg.crops: - logger.info(repr(cropnode)) - crop_nodes.append(str(cropnode)) - continue - - logger.info("".join(crop_nodes)) - logger.info("[test] Finished.") - return - - -if __name__ == "__main__": - test() diff --git a/src/imageCropDivide/node.nk b/src/imageCropDivide/node.nk deleted file mode 100644 index 2b9f9d9..0000000 --- a/src/imageCropDivide/node.nk +++ /dev/null @@ -1,36 +0,0 @@ - -set cut_paste_input [stack 0] -version 12.2 v5 -push $cut_paste_input -Group { - name imageCropDivide - tile_color 0x5c3d84ff - note_font_size 25 - note_font_color 0xffffffff - selected true - xpos 411 - ypos -125 - addUserKnob {20 User} - addUserKnob {3 width_max} - addUserKnob {3 height_max -STARTLINE} - addUserKnob {3 width_source} - addUserKnob {3 height_source -STARTLINE} - addUserKnob {26 "" +STARTLINE} - addUserKnob {22 icd_script l "Copy Setup to ClipBoard" T "\"\"\"\nversion=3\nauthor=Liam Collod\nlast_modified=24/04/2022\npython>2.7\ndependencies=\{\n nuke=*\n\}\n\n[What]\n\nFrom given maximum dimensions, divide an input image into multiples crops.\nThis a combined script of and .\nMust be executed from a python button knob.\n\n[Use]\n\nMust be executed from a python button knob.\n\n[License]\n\nCopyright 2022 Liam Collod\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n\n\"\"\"\n\nimport logging\nimport math\nimport platform\nimport subprocess\nimport sys\n\ntry:\n from typing import Tuple, List\nexcept:\n pass\n\nimport nuke\n\n\ndef setup_logging(name, level):\n logger = logging.getLogger(name)\n logger.setLevel(level)\n\n if not logger.handlers:\n # create a file handler\n handler = logging.StreamHandler(stream=sys.stdout)\n handler.setLevel(logging.DEBUG)\n # create a logging format\n formatter = logging.Formatter(\n '%(asctime)s - [%(levelname)7s] %(name)30s // %(message)s',\n datefmt='%H:%M:%S'\n )\n handler.setFormatter(formatter)\n # add the file handler to the logger\n logger.addHandler(handler)\n\n return logger\n\n\nlogger = setup_logging(\"imageCropDivide.button\", logging.DEBUG)\n\nPASS_METADATA_PATH = \"_crop/passName\"\n\"Metadata key name. Used in write nodes for a flexible pass setup.\"\n\n\nclass CropNode:\n \"\"\"\n When creating an instance no node is created in the Nuke nodegraph. Call update()\n to create the node and access it.\n\n Args:\n x_start(int):\n x_end(int):\n y_start(int):\n y_end(int):\n identifier(str): allow to identify a crop among an array of crop.\n Must be safe to use in a nuke node name\n reformat(bool): enable the reformat option on the crop node if true\n \"\"\"\n\n def __init__(self, x_start, x_end, y_start, y_end, identifier, reformat=False):\n\n self.identifier = identifier\n self.x_start = x_start\n self.x_end = x_end\n self.y_start = y_start\n self.y_end = y_end\n self.reformat = reformat\n\n self.node = None\n\n return\n\n def __repr__(self):\n return \"\{\} : x[\{\} -> \{\}] - y[\{\} -> \{\}] //// xy[\{\}, \{\}] rt[\{\}, \{\}]\".format(\n super(CropNode, self).__repr__(),\n round(self.x_start, 3), round(self.x_end, 3),\n round(self.y_start, 3), round(self.y_end, 3),\n round(self.x, 3), round(self.y, 3),\n round(self.r, 3), round(self.t, 3)\n )\n\n def __str__(self):\n \"\"\"\n Returns:\n str: node formatted as .nk format\n \"\"\"\n out = \"Crop \{\\n\"\n out += \" box \{\{\{\} \{\} \{\} \{\}\}\}\\n\".format(self.x, self.y, self.r, self.t)\n out += \" reformat \{\}\\n\".format(str(self.reformat).lower())\n out += \"\}\\n\"\n return out\n\n @property\n def r(self):\n return self.x_start\n\n @property\n def t(self):\n return self.y_start\n\n @property\n def x(self):\n return self.x_end\n\n @property\n def y(self):\n return self.y_end\n\n def set_name(self, name):\n \"\"\"\n Args:\n name(str):\n \"\"\"\n self.node.setName(name)\n return\n\n def update(self):\n \"\"\"\n Update the Crop Nuke node knobs with the values stored in the class instance.\n If the node was never created yet, it is created.\n \"\"\"\n\n if self.node is None:\n self.__update = False\n self.node = nuke.createNode(\"Crop\")\n self.__update = True\n assert self.node, \"[CropNode][update] Can't create nuke node \{\}\".format(self)\n\n self.node[\"box\"].setX(self.x)\n self.node[\"box\"].setY(self.y)\n self.node[\"box\"].setR(self.r)\n self.node[\"box\"].setT(self.t)\n self.node[\"reformat\"].setValue(self.reformat)\n\n return\n\n\nclass CropGenerator:\n \"\"\"\n\n Args:\n max_size(Tuple[int, int]): (r, t)\n source_size(Tuple[int, int]): (r, t)\n\n Attributes:\n width_max:\n height_max:\n width_source:\n height_source:\n crops: ordered list of CropNode instance created\n \"\"\"\n\n def __init__(self, max_size, source_size):\n\n self.width_max = max_size[0] # type: int\n self.height_max = max_size[1] # type: int\n self.width_source = source_size[0] # type: int\n self.height_source = source_size[1] # type: int\n\n self.crops = list() # type: List[CropNode]\n\n self._generate_crops()\n\n return\n\n def _get_crop_coordinates(self, crop_number, x=True):\n \"\"\"\n Return a list of ``x`` or ``y`` start/end coordinates for the number of crops\n specified.\n\n Args:\n crop_number(int):\n x(bool): return ``x`` coordinates if true else ``y``\n\n Returns:\n Tuple[Tuple[int, int]]: where ((start, end), ...)\n \"\"\"\n\n out = list()\n c = self.width_source if x else self.height_source\n\n for i in range(crop_number):\n start = c / crop_number * i\n end = c / crop_number * i + (c / crop_number)\n out.append((start, end))\n\n return tuple(out)\n\n def _generate_crops(self):\n \"\"\"\n Create the CropNode instance stored in attribute.\n These instance still doesn't exist in the Nuke nodegraph.\n \"\"\"\n\n width_crops_n = math.ceil(self.width_source / self.width_max)\n height_crops_n = math.ceil(self.height_source / self.height_max)\n\n if not width_crops_n or not height_crops_n:\n raise RuntimeError(\n \"[_generate_crops] Can't find a number of crop to perform on r(\{\})\"\n \" or t(\{\}) for the following setup :\\n\"\n \"max=\{\}x\{\} ; source=\{\}x\{\}\".format(\n width_crops_n, height_crops_n, self.width_max, self.height_max,\n self.width_source, self.height_source\n )\n )\n\n width_crops = self._get_crop_coordinates(width_crops_n, x=True)\n height_crops = self._get_crop_coordinates(height_crops_n, x=False)\n\n for width_i in range(len(width_crops)):\n\n for height_i in range(len(height_crops)):\n\n crop = CropNode(\n x_start=width_crops[width_i][0],\n y_start=height_crops[height_i][0],\n x_end=width_crops[width_i][1],\n y_end=height_crops[height_i][1],\n identifier=\"\{\}x\{\}\".format(width_i, height_i)\n )\n self.crops.append(crop)\n logger.debug(\n \"[CropGenerator][_generate_crops] created \{\}\".format(crop.__repr__())\n )\n\n continue\n\n return\n\n\ndef register_in_clipboard(data):\n \"\"\"\n Args:\n data(str):\n \"\"\"\n\n # Check which operating system is running to get the correct copying keyword.\n if platform.system() == 'Darwin':\n copy_keyword = 'pbcopy'\n elif platform.system() == 'Windows':\n copy_keyword = 'clip'\n else:\n raise OSError(\"Current os not supported. Only [Darwin, Windows]\")\n\n subprocess.run(copy_keyword, universal_newlines=True, input=data)\n return\n\n\ndef generate_nk(\n width_max,\n height_max,\n width_source,\n height_source,\n):\n \"\"\"\n\n Args:\n width_max(int):\n height_max(int):\n width_source(int):\n height_source(int):\n\n Returns:\n str: .nk formatted string representing the nodegraph\n \"\"\"\n\n cg = CropGenerator(\n (width_max, height_max),\n (width_source, height_source),\n )\n\n out = str()\n out += \"\"\"set cut_paste_input [stack 0]\nversion 13.1 v3\npush $cut_paste_input\\n\"\"\"\n\n id_write_master = None\n\n for i, cropnode in enumerate(cg.crops):\n\n pos_x = 125 * i\n pos_y = 125\n\n out += \"Dot \{\{\\n xpos \{\}\\n ypos \{\}\\n\}\}\\n\".format(pos_x, pos_y)\n id_last = \"N173200\"\n out += \"set \{\} [stack 0]\\n\".format(id_last)\n pos_y += 125\n\n # CROPNODE\n cropnode.reformat = True\n str_cropnode = str(cropnode)[:-2] # remove the 2 last character \"\}\\n\"\n str_cropnode += \" name Crop_\{\}_\\n\".format(cropnode.identifier)\n str_cropnode += \" xpos \{\}\\n ypos \{\}\\n\".format(pos_x, pos_y)\n str_cropnode += \"\}\\n\"\n out += str_cropnode\n pos_y += 125\n\n # ModifyMetadata node\n out += \"ModifyMetaData \{\\n\"\n out += \" metadata \{\{\{\{set \{\} \{\}\}\}\}\}\\n\".format(PASS_METADATA_PATH,\n cropnode.identifier)\n out += \" xpos \{\}\\n ypos \{\}\\n\".format(pos_x, pos_y)\n out += \"\}\\n\"\n pos_y += 125\n\n # Write node cloning system\n if id_write_master:\n out += \"clone $\{\} \{\{\\n xpos \{\}\\n ypos \{\}\\n\}\}\\n\".format(id_write_master,\n pos_x, pos_y)\n pos_y += 125\n else:\n id_write_master = \"C171d00\"\n out += \"clone node7f6100171d00|Write|21972 Write \{\\n\"\n out += \" xpos \{\}\\n ypos \{\}\\n\".format(pos_x, pos_y)\n out += \" file \\\"[metadata \{\}].jpg\\\"\".format(PASS_METADATA_PATH)\n out += \" file_type jpeg\\n _jpeg_quality 1\\n _jpeg_sub_sampling 4:4:4\\n\"\n out += \"\}\\n\"\n out += \"set \{\} [stack 0]\\n\".format(id_write_master)\n\n out += \"push $\{\}\\n\".format(id_last)\n continue\n\n logger.info(\"[generate_nk] Finished.\")\n return out\n\n\ndef run():\n \"\"\"\n \"\"\"\n logger.info(\"[run] Started.\")\n\n width_max = nuke.thisNode()[\"width_max\"].getValue()\n height_max = nuke.thisNode()[\"height_max\"].getValue()\n width_source = nuke.thisNode()[\"width_source\"].getValue()\n height_source = nuke.thisNode()[\"height_source\"].getValue()\n\n assert width_max, \"ValueError: width_max can't be False/None/0\"\n assert height_max, \"ValueError: height_max can't be False/None/0\"\n assert width_source, \"ValueError: width_source can't be False/None/0\"\n assert height_source, \"ValueError: height_source can't be False/None/0\"\n\n nk_str = generate_nk(\n width_max=width_max,\n height_max=height_max,\n width_source=width_source,\n height_source=height_source,\n )\n register_in_clipboard(nk_str)\n\n logger.info(\"[run] Finished. Nodegraph copied to clipboard.\")\n return\n\n\nrun()" +STARTLINE} - addUserKnob {26 info l " " T "press ctrl+v in the nodegraph after clicking the above button"} - addUserKnob {20 Info} - addUserKnob {26 infotext l "" +STARTLINE T "2022 - Liam Collod
Visit the GitHub repo "} - addUserKnob {26 "" +STARTLINE} - addUserKnob {26 versiontext l "" T "version 7"} -} - Input { - inputs 0 - name Input1 - xpos 0 - } - Output { - name Output1 - xpos 0 - ypos 300 - } -end_group diff --git a/src/imageCropDivide/src/ImageCropDivide-template.nk b/src/imageCropDivide/src/ImageCropDivide-template.nk new file mode 100644 index 0000000..0023ea7 --- /dev/null +++ b/src/imageCropDivide/src/ImageCropDivide-template.nk @@ -0,0 +1,50 @@ +Group { + name imageCropDivide + tile_color 0x5c3d84ff + addUserKnob {20 User} + addUserKnob {26 header_step1 l "" T "

Step1: configure

"} + addUserKnob {3 width_max l "Width Max"} + width_max 1920 + addUserKnob {3 height_max l "Height Max" -STARTLINE} + height_max 1080 + addUserKnob {3 width_source l "Width Source"} + width_source {{width}} + addUserKnob {3 height_source l "Height Source" -STARTLINE} + height_source {{height}} + addUserKnob {2 export_directory l "Export Directory" +STARTLINE} + addUserKnob {1 combined_filepath l "Combined File Path" t "without file extension" +STARTLINE} + addUserKnob {26 "" +STARTLINE} + addUserKnob {26 header_step2 l "" T "

Step2: create crop nodes

" +STARTLINE} + addUserKnob {26 spacer1 l "" T " " +STARTLINE} + addUserKnob {22 icd_script l "Copy Setup to ClipBoard" T "%ICD_SCRIPT%" -STARTLINE} + addUserKnob {26 info l "" T "press ctrl+v in the nodegraph after clicking the above" +STARTLINE} + addUserKnob {26 "" +STARTLINE} + addUserKnob {26 header_step3 l "" T "

Step3: write

" +STARTLINE} + addUserKnob {26 info_step3 l "" T "- edit the top-most write node as wished\n- unclone all the other write node\n- render all write node to disk" +STARTLINE} + addUserKnob {26 "" +STARTLINE} + addUserKnob {26 header_step4 l "" T "

Step4: combine

" +STARTLINE} + addUserKnob {26 info_step4 l "" T "Combine need external programs, it can work with:\n- oiiotool: path to .exe set in OIIOTOOL env var\n- oiiotool: path to .exe set in below knob\n- Pillow python library set in PYTHONPATH env var" +STARTLINE} + addUserKnob {26 spacer2 l "" T " " +STARTLINE} + addUserKnob {22 combine_script l "Combine From Export Directory" T "%COMBINE_SCRIPT%" -STARTLINE} + addUserKnob {26 header_combine l " " T "

options:

" +STARTLINE} + addUserKnob {6 delete_crops l "Delete Crops" t "Delete crops files created once the combined image is finished." +STARTLINE} + delete_crops true + addUserKnob {2 oiiotool_path l "oiiotool path" +STARTLINE} + addUserKnob {20 About} + addUserKnob {26 toolName l name T ImageCropDivide} + addUserKnob {26 toolVersion l version T 1.1.0} + addUserKnob {26 toolAuthor l author T "Liam Collod"} + addUserKnob {26 toolDescription l description T "Crop an image into tiles to be written on disk, and recombine the tiles to a single image."} + addUserKnob {26 toolUrl l url T "https://github.com/MrLixm/Foundry_Nuke"} +} + Input { + inputs 0 + name Input1 + xpos 0 + } + Output { + name Output1 + xpos 0 + ypos 300 + } +end_group diff --git a/src/imageCropDivide/src/Write-master-template.nk b/src/imageCropDivide/src/Write-master-template.nk new file mode 100644 index 0000000..ee0676e --- /dev/null +++ b/src/imageCropDivide/src/Write-master-template.nk @@ -0,0 +1,8 @@ +Write { + xpos 0 + ypos -100 + file "[value %ICD_NODE%.export_directory]/[metadata %METADATA_KEY%].jpg" + file_type jpeg + _jpeg_quality 1 + _jpeg_sub_sampling 4:4:4 +} \ No newline at end of file diff --git a/src/imageCropDivide/src/Write-pass-template.nk b/src/imageCropDivide/src/Write-pass-template.nk new file mode 100644 index 0000000..a9ca562 --- /dev/null +++ b/src/imageCropDivide/src/Write-pass-template.nk @@ -0,0 +1,22 @@ +Dot { + name Dot_%PASS_ID%_1 + xpos %PASS_XPOS% + ypos 0 +} +Crop { + name Crop_%PASS_ID%_1 + xpos %PASS_XPOS% + ypos 50 + box {%BOX_X% %BOX_Y% %BOX_R% %BOX_T%} + reformat true +} +ModifyMetaData { + name ModifyMetaData_%PASS_ID%_1 + xpos %PASS_XPOS% + ypos 100 + metadata {{set %METADATA_KEY% %PASS_ID%}} +} +clone $%WRITE_CLONE_ID% { + xpos %PASS_XPOS% + ypos 150 +} diff --git a/src/imageCropDivide/src/btn-script-template.py b/src/imageCropDivide/src/btn-script-template.py new file mode 100644 index 0000000..d7d3d25 --- /dev/null +++ b/src/imageCropDivide/src/btn-script-template.py @@ -0,0 +1,268 @@ +""" +version=4 +author=Liam Collod +last_modified=24/04/2022 +python>2.7 +dependencies={ + nuke=* +} + +[What] + +From given maximum dimensions, divide an input image into multiples crops. +This a combined script of and . +Must be executed from a python button knob. + +[Use] + +Must be executed from a python button knob. + +[License] + +Copyright 2022 Liam Collod +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 logging +import math +import platform +import subprocess +import sys + +try: + from typing import Tuple, List +except ImportError: + pass + +import nuke + + +LOGGER = logging.getLogger("{}.{}".format(nuke.thisNode(), nuke.thisKnob())) + +# dynamically replaced on build +PASS_NUKE_TEMPLATE = "%PASS_NUKE_TEMPLATE%" +WRITE_MASTER_NUKE_TEMPLATE = "%WRITE_MASTER_NUKE_TEMPLATE%" + + +class CropCoordinate: + """ + Dataclass or "struct" that just hold multipel attribute represent a crop coordinates. + """ + + def __init__(self, x_start, y_start, x_end, y_end, width_index, height_index): + self.x_start = x_start + self.y_start = y_start + self.x_end = x_end + self.y_end = y_end + self.width_index = width_index + self.height_index = height_index + + +def generate_crop_coordinates(width_max, height_max, width_source, height_source): + """ + Split the guven source coordinates area into multiple crops which are all tiles + of the same size but which can have width!=height. + + This implies that the combination of the crop might be better than the source area + and need to be cropped. + + Args: + width_max (int): maximum allowed width for each crop + height_max (int): maximum allowed height for each crop + width_source (int): width of the source to crop + height_source (int): height of the source to crop + + Returns: + list[CropCoordinate]: list of crops to perform to match the given parameters requested + """ + # ceil to get the biggest number of crops + width_crops_n = math.ceil(width_source / width_max) + height_crops_n = math.ceil(height_source / height_max) + # floor to get maximal crop dimension + width_crop = math.ceil(width_source / width_crops_n) + height_crop = math.ceil(height_source / height_crops_n) + + if not width_crops_n or not height_crops_n: + raise RuntimeError( + "[generate_crop_coordinates] Can't find a number of crop to perform on r({})" + " or t({}) for the following setup :\n" + "max={}x{} ; source={}x{}".format( + width_crops_n, + height_crops_n, + width_max, + height_max, + width_source, + height_source, + ) + ) + + width_crops = [] + + for i in range(width_crops_n): + start = width_crop * i + end = width_crop * i + width_crop + width_crops.append((start, end)) + + height_crops = [] + + for i in range(height_crops_n): + start = height_crop * i + end = height_crop * i + height_crop + height_crops.append((start, end)) + + # nuke assume 0,0 is bottom left but we want 0,0 to be top-left + height_crops.reverse() + + crops = [] + + for width_i, width in enumerate(width_crops): + for height_i, height in enumerate(height_crops): + crop = CropCoordinate( + x_start=width[0], + y_start=height[0], + x_end=width[1], + y_end=height[1], + # XXX: indexes start at 1 + width_index=width_i + 1, + height_index=height_i + 1, + ) + crops.append(crop) + + # a 2x2 image is indexed like + # [1 3] + # [2 4] + + return crops + + +def register_in_clipboard(data): + """ + Args: + data(str): + """ + + # Check which operating system is running to get the correct copying keyword. + if platform.system() == "Darwin": + copy_keyword = "pbcopy" + elif platform.system() == "Windows": + copy_keyword = "clip" + else: + raise OSError("Current os not supported. Only [Darwin, Windows]") + + subprocess.run(copy_keyword, universal_newlines=True, input=data) + return + + +def generate_nk( + width_max, + height_max, + width_source, + height_source, + node_name, +): + """ + + Args: + width_max(int): + height_max(int): + width_source(int): + height_source(int): + node_name(str): + + Returns: + str: .nk formatted string representing the nodegraph + """ + + crop_coordinates = generate_crop_coordinates( + width_max, + height_max, + width_source, + height_source, + ) + + out = "" + + master_write_id = "C171d00" + pass_metadata_key = "__crop/pass_id" + + master_write = WRITE_MASTER_NUKE_TEMPLATE.replace( + "%METADATA_KEY%", pass_metadata_key + ) + master_write = master_write.replace("%ICD_NODE%", node_name) + out += "clone node7f6100171d00|Write|21972 {}\n".format(master_write) + out += "set {} [stack 0]\n".format(master_write_id) + + for index, crop_coordinate in enumerate( + crop_coordinates + ): # type: int, CropCoordinate + pass_nk = PASS_NUKE_TEMPLATE + pass_id = "{}x{}".format( + crop_coordinate.width_index, crop_coordinate.height_index + ) + pos_x = 125 * index + + pass_nk = pass_nk.replace("%PASS_ID%", str(pass_id)) + pass_nk = pass_nk.replace("%PASS_XPOS%", str(pos_x)) + pass_nk = pass_nk.replace("%WRITE_CLONE_ID%", str(master_write_id)) + pass_nk = pass_nk.replace("%METADATA_KEY%", str(pass_metadata_key)) + pass_nk = pass_nk.replace("%BOX_X%", str(crop_coordinate.x_end)) + pass_nk = pass_nk.replace("%BOX_Y%", str(crop_coordinate.y_end)) + pass_nk = pass_nk.replace("%BOX_R%", str(crop_coordinate.x_start)) + pass_nk = pass_nk.replace("%BOX_T%", str(crop_coordinate.y_start)) + + out += "{}push ${}\n".format(pass_nk, master_write_id) + continue + + LOGGER.info("[generate_nk] Finished.") + return out + + +def run(): + def _check(variable, name): + if not variable: + raise ValueError("{} can't be False/None/0".format(name)) + + LOGGER.info("[run] Started.") + + width_max = nuke.thisNode()["width_max"].getValue() + height_max = nuke.thisNode()["height_max"].getValue() + width_source = nuke.thisNode()["width_source"].getValue() + height_source = nuke.thisNode()["height_source"].getValue() + node_name = nuke.thisNode().name() + + _check(width_max, "width_max") + _check(height_max, "height_max") + _check(width_source, "width_source") + _check(height_source, "height_source") + + nk_str = generate_nk( + width_max=width_max, + height_max=height_max, + width_source=width_source, + height_source=height_source, + node_name=node_name, + ) + register_in_clipboard(nk_str) + + LOGGER.info("[run] Finished. Nodegraph copied to clipboard.") + return + + +# remember: this modifies the root LOGGER only if it never has been before +logging.basicConfig( + level=logging.INFO, + format="%(levelname)-7s | %(asctime)s [%(name)s] %(message)s", + stream=sys.stdout, +) +run() diff --git a/src/imageCropDivide/src/build.py b/src/imageCropDivide/src/build.py new file mode 100644 index 0000000..562fe96 --- /dev/null +++ b/src/imageCropDivide/src/build.py @@ -0,0 +1,98 @@ +# python 3 +import logging +import sys +from pathlib import Path + +LOGGER = logging.getLogger(__name__) +THIS_DIR = Path(__file__).parent + + +class BuildPaths: + src_btn_script = THIS_DIR / "btn-script-template.py" + assert src_btn_script.exists() + + src_combine_script = THIS_DIR / "combine-script.py" + assert src_combine_script.exists() + + src_gizmo = THIS_DIR / "ImageCropDivide-template.nk" + assert src_gizmo.exists() + + src_pass_nk = THIS_DIR / "Write-pass-template.nk" + assert src_pass_nk.exists() + + src_write_nk = THIS_DIR / "Write-master-template.nk" + assert src_write_nk.exists() + + build_dir = THIS_DIR.parent + build_gizmo = build_dir / "ImageCropDivide.nk" + + +def sanitize_nuke_script(script: str, convert_new_lines=True) -> str: + if convert_new_lines: + newscript = script.replace("\\", r"\\") + newscript = newscript.split("\n") + newscript = r"\n".join(newscript) + else: + newscript = script.split(r"\n") + newscript = [line.replace("\\", r"\\") for line in newscript] + newscript = r"\n".join(newscript) + + newscript = newscript.replace('"', r"\"") + newscript = newscript.replace("{", r"\{") + newscript = newscript.replace("}", r"\}") + return newscript + + +def build_python_script() -> str: + base_script = BuildPaths.src_btn_script.read_text("utf-8") + + nuke_pass_template = BuildPaths.src_pass_nk.read_text("utf-8") + nuke_write_template = BuildPaths.src_write_nk.read_text("utf-8") + + base_script = base_script.replace( + '"%PASS_NUKE_TEMPLATE%"', + repr(nuke_pass_template), + ) + base_script = base_script.replace( + '"%WRITE_MASTER_NUKE_TEMPLATE%"', + repr(nuke_write_template), + ) + return sanitize_nuke_script(base_script) + + +def build_combine_script(): + return sanitize_nuke_script(BuildPaths.src_combine_script.read_text("utf-8")) + + +def build(): + LOGGER.info(f"build started") + base_gizmo = BuildPaths.src_gizmo.read_text("utf-8") + btn_py_script = build_python_script() + combine_py_script = build_combine_script() + + new_gizmo = [] + + for line_index, line in enumerate(base_gizmo.split("\n")): + if "%ICD_SCRIPT%" in line: + line = line.replace("%ICD_SCRIPT%", btn_py_script) + LOGGER.debug(f"replaced ICD_SCRIPT") + if "%COMBINE_SCRIPT%" in line: + line = line.replace("%COMBINE_SCRIPT%", combine_py_script) + LOGGER.debug(f"replaced COMBINE_SCRIPT") + + new_gizmo.append(line) + + new_gizmo = "\n".join(new_gizmo) + LOGGER.info(f"writting {BuildPaths.build_gizmo}") + BuildPaths.build_gizmo.write_text(new_gizmo, "utf-8") + LOGGER.info("build finished") + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.DEBUG, + format="{levelname: <7} | {asctime} [{name}] {message}", + style="{", + stream=sys.stdout, + ) + build() diff --git a/src/imageCropDivide/src/combine-script.py b/src/imageCropDivide/src/combine-script.py new file mode 100644 index 0000000..625efaa --- /dev/null +++ b/src/imageCropDivide/src/combine-script.py @@ -0,0 +1,297 @@ +import abc +import logging +import os +import subprocess +import sys + +import nuke + +LOGGER = logging.getLogger(__name__) + + +class BaseCombineMethod: + name = "" + + def __init__(self, *args, **kwargs): + if not self.name: + raise NotImplementedError("name attribute must be implemented") + + @abc.abstractmethod + def run( + self, + directory, + combined_filepath, + delete_crops, + target_width, + target_height, + ): + """ + + Args: + directory(str): filesystem path to an existing directory with file inside + combined_filepath(str): valid filesystem file name without extension + delete_crops(bool): True to delete crops once combined + target_width(int): taregt width of the combined image + target_height(int): taregt height of the combined image + + Returns: + str: filesystem path to the combined file created + """ + pass + + +def find_crop_images_in_dir(directory): + """ + Args: + directory(str): filesystem path to an existing directory with file inside + + Returns: + list[str]: list of existing files + """ + # XXX: we assume directory only contains the images we want to combine but + # we still perform some sanity checks just in case + src_files = [ + os.path.join(directory, filename) for filename in os.listdir(directory) + ] + src_ext = os.path.splitext(src_files[0])[1] + src_files = [ + filepath + for filepath in src_files + if os.path.isfile(filepath) and filepath.endswith(src_ext) + ] + return src_files + + +def sort_crops_paths_topleft_rowcolumn(crop_paths): + """ + Change the order of the given list of images so it correspond to a list of crop + starting from the top-left, doing rows then columns. + + Example for a 2x3 image:: + + [1 2] + [3 4] + [5 6] + + Args: + crop_paths: list of file paths exported by the ICD node. + + Returns: + new list of same file paths but sorted differently. + """ + + # copy + _crop_paths = list(crop_paths) + _crop_paths.sort() + + _, mosaic_max_height = get_grid_size(crop_paths) + + # for a 2x3 image we need to convert like : + # [1 4] > [1 2] + # [2 5] > [3 4] + # [3 6] > [5 6] + buffer = [] + for row_index in range(mosaic_max_height): + buffer += _crop_paths[row_index::mosaic_max_height] + + return buffer + + +def get_grid_size(crop_paths): + """ + Returns: + tuple[int, int]: (columns number, rows number). + """ + # copy + _crop_paths = list(crop_paths) + _crop_paths.sort() + # name of a file is like "0x2.jpg" + mosaic_max = os.path.splitext(os.path.basename(_crop_paths[-1]))[0] + mosaic_max_width = int(mosaic_max.split("x")[0]) + mosaic_max_height = int(mosaic_max.split("x")[1]) + return mosaic_max_width, mosaic_max_height + + +class OiiotoolCombineMethod(BaseCombineMethod): + name = "oiiotool executable" + + def __init__(self, oiiotool_path=None, *args, **kwargs): + super(OiiotoolCombineMethod, self).__init__() + if oiiotool_path: + self._oiiotool_path = oiiotool_path + else: + self._oiiotool_path = os.getenv("OIIOTOOL") + + if not self._oiiotool_path: + raise ValueError("No oiiotool path found.") + if not os.path.exists(self._oiiotool_path): + raise ValueError( + "Oiiotool path provide doesn't exist: {}".format(oiiotool_path) + ) + + def run( + self, + directory, + combined_filepath, + delete_crops, + target_width, + target_height, + ): + src_files = find_crop_images_in_dir(directory) + src_ext = os.path.splitext(src_files[0])[1] + if not src_files: + raise ValueError( + "Cannot find crops files to combine in {}".format(directory) + ) + + dst_file = combined_filepath + src_ext + + src_files = sort_crops_paths_topleft_rowcolumn(src_files) + tiles_size = get_grid_size(src_files) + + command = [self._oiiotool_path] + command += src_files + # https://openimageio.readthedocs.io/en/latest/oiiotool.html#cmdoption-mosaic + # XXX: needed so hack explained under works + command += ["--metamerge"] + command += ["--mosaic", "{}x{}".format(tiles_size[0], tiles_size[1])] + command += ["--cut", "0,0,{},{}".format(target_width - 1, target_height - 1)] + # XXX: hack to preserve metadata that is lost with the mosaic operation + command += ["-i", src_files[0], "--chappend"] + command += ["-o", dst_file] + + LOGGER.info("about to call oiiotool with {}".format(command)) + subprocess.check_call(command) + + if not os.path.exists(dst_file): + raise RuntimeError( + "Unexpected issue: combined file doesn't exist on disk at <{}>" + "".format(dst_file) + ) + + if delete_crops: + for src_file in src_files: + os.unlink(src_file) + + return dst_file + + +class PillowCombineMethod(BaseCombineMethod): + name = "python Pillow library" + + def __init__(self, *args, **kwargs): + super(PillowCombineMethod, self).__init__() + # expected to raise if PIL not available + from PIL import Image + + def run( + self, + directory, + combined_filepath, + delete_crops, + target_width, + target_height, + ): + from PIL import Image + + src_files = find_crop_images_in_dir(directory) + src_files = sort_crops_paths_topleft_rowcolumn(src_files) + column_number, row_number = get_grid_size(src_files) + + src_ext = os.path.splitext(src_files[0])[1] + dst_file = combined_filepath + src_ext + + images = [Image.open(filepath) for filepath in src_files] + # XXX: assume all crops have the same size + tile_size = images[0].size + + # XXX: we use an existing image for our new image so we preserve metadata + combined_image = Image.open(src_files[0]) + buffer_image = Image.new( + mode=combined_image.mode, size=(target_width, target_height) + ) + # XXX: part of the hack to preserve metadata, we do that because image.resize sucks + # and doesn't return an exact copy of the initial instance + combined_image.im = buffer_image.im + combined_image._size = buffer_image._size + image_index = 0 + + for column_index in range(column_number): + for row_index in range(row_number): + image = images[image_index] + image_index += 1 + coordinates = (tile_size[0] * row_index, tile_size[1] * column_index) + combined_image.paste(image, box=coordinates) + + save_kwargs = {} + if src_ext.startswith(".jpg"): + save_kwargs = { + "quality": "keep", + "subsampling": "keep", + "qtables": "keep", + } + + combined_image.save(fp=dst_file, **save_kwargs) + + if delete_crops: + for src_file in src_files: + os.unlink(src_file) + + return dst_file + + +COMBINE_METHODS = [ + OiiotoolCombineMethod, + PillowCombineMethod, +] + + +def run(): + LOGGER.info("[run] Started.") + + export_dir = nuke.thisNode()["export_directory"].evaluate() # type: str + combined_filepath = nuke.thisNode()["combined_filepath"].evaluate() # type: str + delete_crops = nuke.thisNode()["delete_crops"].getValue() # type: bool + oiiotool_path = nuke.thisNode()["oiiotool_path"].evaluate() # type: str + width_source = int(nuke.thisNode()["width_source"].getValue()) # type: int + height_source = int(nuke.thisNode()["height_source"].getValue()) # type: int + + if not export_dir or not os.path.isdir(export_dir): + raise ValueError( + "Invalid export directory <{}>: not found on disk.".format(export_dir) + ) + + combine_instance = None + + for combine_method_class in COMBINE_METHODS: + try: + combine_instance = combine_method_class(oiiotool_path=oiiotool_path) + except Exception as error: + LOGGER.debug("skipping class {}: {}".format(combine_method_class, error)) + + if not combine_instance: + raise RuntimeError( + "No available method to combine the renders found. Available methods are:\n{}" + "\nSee documentation for details." + "".format([method.name for method in COMBINE_METHODS]) + ) + + LOGGER.info("[run] about to combine directory {} ...".format(export_dir)) + combined_filepath = combine_instance.run( + directory=export_dir, + delete_crops=delete_crops, + combined_filepath=combined_filepath, + target_width=width_source, + target_height=height_source, + ) + nuke.message("Successfully created combine file: {}".format(combined_filepath)) + LOGGER.info("[run] Finished.") + + +# remember: this modifies the root LOGGER only if it never has been before +logging.basicConfig( + level=logging.INFO, + format="%(levelname)-7s | %(asctime)s [%(name)s] %(message)s", + stream=sys.stdout, +) +run()