-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathImageCropDivide.nk
50 lines (50 loc) · 20.4 KB
/
ImageCropDivide.nk
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Group {
name imageCropDivide
tile_color 0x5c3d84ff
addUserKnob {20 User}
addUserKnob {26 header_step1 l "" T "<h2>Step1: configure</h2>"}
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 "<h2>Step2: create crop nodes</h2>" +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 <cropAndWrite> and <imageCropDivide>.\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 "<h2>Step3: write</h2>" +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 "<h2>Step4: combine</h2>" +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 "<h3>options:</h3>" +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 "<a style=\"color: rgb(200,200,200);\" href=\"https://mrlixm.github.io/\">Liam Collod</a>"}
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 "<a style=\"color: rgb(200,200,200);\" href=\"https://github.com/MrLixm/Foundry_Nuke\">https://github.com/MrLixm/Foundry_Nuke</a>"}
}
Input {
inputs 0
name Input1
xpos 0
}
Output {
name Output1
xpos 0
ypos 300
}
end_group