├── imagepacker
├── imagepacker
│ ├── __init__.py
│ ├── __pycache__
│ │ ├── __init__.cpython-34.pyc
│ │ ├── __init__.cpython-38.pyc
│ │ ├── imagepacker.cpython-34.pyc
│ │ └── imagepacker.cpython-38.pyc
│ └── imagepacker.py
├── script.sh
├── LICENSE
├── README.md
└── objuvpacker.py
└── README.md
/imagepacker/imagepacker/__init__.py:
--------------------------------------------------------------------------------
1 | from .imagepacker import pack_images
2 |
--------------------------------------------------------------------------------
/imagepacker/script.sh:
--------------------------------------------------------------------------------
1 | python .\objuvpacker.py .\SkyPark_SG.obj -m .\SkyPark_SG.mtl -o Model_SkyPark_SG
--------------------------------------------------------------------------------
/imagepacker/imagepacker/__pycache__/__init__.cpython-34.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenyingshu/google_3dtile_collection/HEAD/imagepacker/imagepacker/__pycache__/__init__.cpython-34.pyc
--------------------------------------------------------------------------------
/imagepacker/imagepacker/__pycache__/__init__.cpython-38.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenyingshu/google_3dtile_collection/HEAD/imagepacker/imagepacker/__pycache__/__init__.cpython-38.pyc
--------------------------------------------------------------------------------
/imagepacker/imagepacker/__pycache__/imagepacker.cpython-34.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenyingshu/google_3dtile_collection/HEAD/imagepacker/imagepacker/__pycache__/imagepacker.cpython-34.pyc
--------------------------------------------------------------------------------
/imagepacker/imagepacker/__pycache__/imagepacker.cpython-38.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenyingshu/google_3dtile_collection/HEAD/imagepacker/imagepacker/__pycache__/imagepacker.cpython-38.pyc
--------------------------------------------------------------------------------
/imagepacker/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Luke
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Google 3D Tiles Collection
2 | Use Google Tile API to download models in [3D tiles](https://developers.google.com/maps/documentation/tile/3d-tiles) to Blender and export as an OBJ file with texture.
3 |
4 | By using Google 3D Tiles you are bound by
5 | - [Google’s Terms of Service](http://www.google.com/intl/en/policies/terms)
6 | - [Google Maps Platform Terms of Service](https://cloud.google.com/maps-platform/terms)
7 | - [Google Privacy Policy](http://www.google.com/policies/privacy)
8 |
9 | :fire: **News and Applications** :fire:
10 |
11 | This pipeline and script were used for ECCV 2024 paper [StyleCity: Large-Scale 3D Urban Scenes Stylization](https://www.chenyingshu.com/stylecity3d/), a system that stylizes city models given text and image references :cityscape: :city_sunset: :night_with_stars:.
12 | - Example models used in the paper: [[OneDrive]](https://hkustconnect-my.sharepoint.com/:f:/g/personal/ychengw_connect_ust_hk/EhPJh29tjFdFtFEQxQdX1HgB7T8c2hLz-sJbaiEvXwzoUw?e=xduk6g).
13 |
14 |
15 | ## References
16 | We refer to the instructions and scripts from and acknowledge the contribution of:
17 | - Import Google 3D tiles to Blender: https://github.com/vvoovv/blosm/wiki/Import-of-Google-3D-Cities.
18 | - Merge many texture files into one for an OBJ file: https://github.com/theFroh/imagepacker.
19 |
20 | ## Requirements
21 | - Python, Pillow
22 | - [Blender](https://www.blender.org/) (latest version)
23 | - Blender Addon [Blosm](https://prochitecture.gumroad.com/l/blender-osm) (base version)
24 | - Google 3D Tiles API Key
25 |
26 | ## Instructions
27 | - [Import Tiles to Blender](#import-tiles-to-blender)
28 | - [Merge Multi-Texture into One](#merge-multi-texture-into-one)
29 | ### Import Tiles to Blender
30 | For detailed instructions, you can follow https://github.com/vvoovv/blosm/wiki/Import-of-Google-3D-Cities.
31 |
32 | #### STEP 1 - Preparation
33 | 1. Install [Blender](https://www.blender.org/)
34 | 2. Install [Blosm Addon (base version)](https://prochitecture.gumroad.com/l/blender-osm)
35 | 3. Get Google 3D Tiles Key [[link]](https://developers.google.com/maps/documentation/tile/get-api-key).
36 | 1. Do not apply any restrictions to your 3D Tiles Key.
37 | 2. Enter key to "Google 3D Tiles Key" Blosm addon preference.
38 | 4. Enable the Maps Tiles API [[link]](https://developers.google.com/maps/documentation/tile/cloud-setup#enabling-apis).
39 |
40 | #### STEP 2 - Import and Export Model
41 | 1. Create a new empty Blender project, enable Blosm addon, and restart Blender (if first time enable addon).
42 | 2. Enter "Directory to store downloaded OpenStreetMap and terrain file" in Blosm addon preference.
43 | 3. (Refer to [Basic Usage](https://github.com/vvoovv/blosm/wiki/Import-of-Google-3D-Cities#basic-usage))
44 | In project "Layout" tab, open "Blosm" menu UI on the right side panel of the Blender (or toggle by click ``N`` key).
45 | 4. Click `Select`, and open web-based UI to select area by bounding box, I use 0.3km x 0.3km to 1.0km x 1.0km area for quicker download.
46 | 5. Click `copy` from Web and `paste` on Blosm UI panel to paste longitudes and latitudes.
47 | 6. Settings:
48 | - Import: `Google 3D Tiles`
49 | - Level of details: `buildings with more details`
50 | - [ ] Join 3D Tiles objects
51 | - [x] Relative to initial import
52 |
Disable "Join 3D Tiles objects" for later mesh editing.
53 | 7. Click `import` and wait, we can open terminal to monitor the downloading progress.
54 | 8. Repeat sub-steps 3-7 to get other aligned tiles.
55 | 9. To better view the model online, on the right View panel, set _View > (Clip) End to 10000m_.
56 |
57 | #### STEP 3 - Export OBJ Model
58 | 1. Usually downloaded tiles cover larger/unexpected area then selected area, please feel free to delete useless tiles.
59 | 2. Change materials: select imported meshes, in Blosm *Tools >Replace materials with* `export-ready`, click `Replace Materials`.
60 | 3. Export and save textures: _Main Menu > File > External Data > Unpack Resources_, in the popup-window
61 | - Select "Write files to current directory (overwrite existing files)"; OR
62 | - For Windows, select "Use files in current directory (create when necessary)"
63 | - For Linux/Ubuntu, select "Use files in original location (create when necessary)"
64 | - There appears duplicate naming in unpacking issue in some Linux Blender.
65 | 5. Select necessary tile meshes.
66 | 6. [OPTIONAL] Recommended for some renderer that supports only one mesh, or small-scale model.
67 | - Press "Ctrl+J" to merge tile meshes into one.
68 | - Rescale the mesh with 0.1 ratio to scale down model size.
69 | 7. _Main Menu > File > Export > Waterfront (.obj)_, in popup-window check _Limit to Selected Only_.
70 | 8. Click `Export Waterfront OBJ` to export textured obj file.
71 |
72 | ### Merge Multi-Texture into One
73 | #### STEP 4 - Pack Textures into One
74 | **Note:** This is inapplicable for files processed with optional Step 3.5.
75 |
76 | Merge texture images into one and use only one material, please run:
77 | ``python objuvpacker.py [path\_to\_obj\_file.obj] -m [path\_to\_material\_file.mtl] -o [output\_path\_name]
78 | ``
79 |
80 | For example,
81 | ``
82 | python imagepacker/objuvpacker.py XXX.obj
83 | ``
84 |
85 | To preserve original multiple materials in obj file by running:
86 | ``
87 | python imagepacker/objuvpacker.py XXX.obj --multi-mtl
88 | ``
89 |
90 | Code was adapted from https://github.com/theFroh/imagepacker.
91 |
--------------------------------------------------------------------------------
/imagepacker/README.md:
--------------------------------------------------------------------------------
1 | # Image Packer
2 | Takes a Wavefront OBJ with many textures and squishes them into a single texture file.
3 |
4 | ## Why?
5 | I put this together for the purpose of packing complex models with multiple textures (practically any non-trivial model) into a single `.obj` and texture file for use as custom models in [Tabletop Simulator](http://berserk-games.com/tabletop-simulator/).
6 | Initially, I had to do this by hand -- first, combining the textures in GIMP and then going through in Blender moving and scaling UV's to fit.
7 |
8 | This process felt awfully repetitive, as well as awfully automatable, cue this script.
9 |
10 | It takes in an `.obj` model file with accompanying `.mtl` and analyses what texture files it uses, as well as how much of each texture file is used. With this information, the script optionally crops textures down to just what is used before packing them into a single texture. Finally, a copy of the original `.obj` is output with updated UV coordinates (corresponding to the new, single texture). An updated `.mtl` is also output.
11 |
12 | ## Dependencies
13 | Written in [*Python 3*](https://www.python.org/downloads/), and using the great [*Pillow*](https://python-pillow.github.io/) image processing/manipulation library.
14 | You can install the former and use the `pip` tool it should bring to grab *Pillow*:
15 |
16 | pip install pillow
17 |
18 | And that's all that the script should depend on.
19 |
20 | ## Usage
21 | Ensure you've got *Python 3* and *Pillow* installed, then nab the packer script (including its subfolder with `imagepacker.py`) by [downloading a ZIP of the repo](https://github.com/theFroh/imagepacker/archive/master.zip).
22 | Extract this archive somewhere you wont forget!
23 |
24 | ### Packing a model
25 | Drag and drop an `.obj` file onto `objuvpacker.py` and behold the results inside of the `_packed` folder next to your .obj.
26 |
27 | #### In more depth:
28 |
29 | 1. Ensure the model you wish to pack is in the Wavefront OBJ format (I've only tested using Blender exports) and that it has an MTL material definition file
30 | 2. Ensure the textures the `.mtl` refers to exist and are accessible (note; the script will check locally for the texture files *first*, before checking the full path), and that they are in a suitable format supported by Pillow (such as: `.tga`, `.jpg`, `.png`, `.bmp`, etc.)
31 | 3. Now either;
32 | - Drag and drop your `.obj` file onto `objuvpacker.py` to use the default settings
33 |
34 | or,
35 |
36 | - Run `python objuvpacker.py [path to your .obj file]` in a terminal (possibly to use the arguments described later)
37 | 4. Inspect the packed output `.obj` and texture inside of the output directory (a folder named after the original `.obj` with `_packed` appended to it) to see if everything went well.
38 | 5. If you're doing this for Tabletop Simulator, convert the packed texture into a more compressed form (`.jpg`) if you do not need the transparency *(you usually don't)*, and then upload the `.obj` and texture file for use ingame -- have fun!
39 |
40 | #### Arguments
41 |
42 | - *Material* `-m --material MATERIAL` - Explicitly tell the script what `.mtl` file to use.
43 | - *Output* `-o --output OUTPUT` - Explicitly tell the script where output to.
44 | - *Add* `-a --add [ADD, ...]` - Additional images to be packed. (Probably useless)
45 | - *No crop* `--no-crop` - Disable any cropping or tiling/unrolling.
46 | - *No tile* `--no-tile` - Ignore any wrapped/tiling of textures (depends on cropping).
47 |
48 | #### Troubleshooting
49 | If you're having trouble packing a model, you can try running the script in a terminal with `--no-crop` and `--no-wrap`. This will use the simplest possible packing, but should be fairly solid.
50 |
51 | #### Tiling or wrapping warnings
52 | You may see a prompt similar to:
53 |
54 | WARNING: The following texture has coordinates that imply it tiles 0.8x10.7 times:
55 | E:\Syncthing\code\obj texture packer\sample\raider\predator_track_l.tga
56 | This may be intentional (i.e. tank track textures), or a sign of problematic UV coordinates.
57 | Consider only unwrapping/tiling this if you know that it is intentional.
58 | (If you are unsure, just hit enter to answer 'No')
59 | Do you want to unroll this wrapping by tiling the texture? [y/N]:
60 |
61 | Then you should read the prompt. This arises when an object has UV coordinates, or entire islands, outside of the usual range of `[0,1]`, which could be for a few reasons:
62 |
63 | 1. The texture in question is intended to tile or repeat multiple times, such as track links for a tank. In this case, I'd advise answering `yes` to tiling to avoid having missing textures on the model's tracks.
64 | 2. The islands/UV coordinates in question were put outside of the usual space because they aren't used. Answer `no` (or press enter, it defaults to `no`). These shouldn't have a visible effect on the final model.
65 | 3. It is a bug, or the modeller has purposefully made use of the UV wrapping behaviour for layout purposes. Answer `no`, check if it causes unwanted texture issues (and isn't a case of `2.`), and if it does produce issues, manually shift the troublesome UV's into place in the *original model (before packing, to save you doing it again later)* using your favourite 3D editor.
66 |
67 | In the example message given above, it should be clear that the texture in question is likely a tank track segment, and that this track repeats nearly 11 times vertically -- it would definitely be wise to answer `yes` to unroll the texture so that the model's tracks are fully textured.
68 |
69 | ##### Why does this happen?
70 | Game and 3D rendering engines tend to treat a texture as infinitely tiling in every direction for the sake of UV coordinates. A UV face that totally encompasses what would usually be the entire texture is effectively filled with repetitions of the texture. UV Faces outside of where the texture usually ends are filled with whatever part of the texture would lie under them if it was repeated to reach.
71 |
72 | For example, a very small texture consisting of a single tank track segment. The tank track model itself may be very long, so its UV's may be arranged to continue well beyond the tiny segment's texture space -- instead of not being textured, the engine rendering the tank model simply internally repeats that tiny track segment along the entire track model's UV faces, texturing the entire track with only a tiny actual texture.
73 |
74 | I'll have to add a diagram to help illustrate the above, but because the script combines multiple textures into one larger texture, each texture no longer infinitely tiles in each direction, so models which intentionally "wrap" a texture many times must be "unrolled" -- tiled so that the UV faces in question actually have the correct texture to display.
75 |
76 | To make things tricky, modelers sometimes intentionally put UV islands outside of the normal space, and sometimes importer bugs or differing coordinate systems can do the same. All of these will look fine in a 3D editor, but if packed without unrolling/wrapping/tiling can sometimes look incorrect.
77 |
78 | ## Technical details
79 |
80 | I'm surprised Blender doesn't have a built in tool that does this.
81 |
82 | Nothing special going on here, a simple rectangle packing algorithm is used to pack representations of the cropped textures fairly tightly. The algorithm is not optimal, and is partly vertically biased. There can be a lot of whitespace in some of its solutions, especially as it does not try to rotate rectangles, but this isn't really an issue as whitespace compresses well.
83 |
84 | I wasn't planning on releasing this script as is, but I think its usefulness to Warhammer 40k players seeking to bring armies into Tabletop Simulator outweighs waiting until I get around to wrapping this in a nice GUI. The code is fairly haphazard in places, apologies!
85 |
--------------------------------------------------------------------------------
/imagepacker/imagepacker/imagepacker.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/python
2 |
3 | # The MIT License (MIT)
4 |
5 | # Copyright (c) 2015 Luke Gaynor
6 |
7 | # Permission is hereby granted, free of charge, to any person obtaining a copy
8 | # of this software and associated documentation files (the "Software"), to deal
9 | # in the Software without restriction, including without limitation the rights
10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | # copies of the Software, and to permit persons to whom the Software is
12 | # furnished to do so, subject to the following conditions:
13 |
14 | # The above copyright notice and this permission notice shall be included in all
15 | # copies or substantial portions of the Software.
16 |
17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 | # SOFTWARE.
24 |
25 | from PIL import Image, ImageDraw
26 | import math
27 | from pprint import pprint
28 |
29 | # Based off of the great writeup, demo and code at:
30 | # http://codeincomplete.com/posts/2011/5/7/bin_packing/
31 |
32 | class Block():
33 | """A rectangular block, to be packed"""
34 | def __init__(self, w, h, data=None, padding=0):
35 | self.w = w
36 | self.h = h
37 | self.x = None
38 | self.y = None
39 | self.fit = None
40 | self.data = data
41 | self.padding = padding # not implemented yet
42 |
43 | def __str__(self):
44 | return "({x},{y}) ({w}x{h}): {data}".format(
45 | x=self.x,y=self.y, w=self.w,h=self.h, data=self.data)
46 |
47 |
48 | class _BlockNode():
49 | """A BlockPacker node"""
50 | def __init__(self, x, y, w, h, used=False, right=None, down=None):
51 | self.x = x
52 | self.y = y
53 | self.w = w
54 | self.h = h
55 | self.used = used
56 | self.right = right
57 | self.down = down
58 |
59 | def __repr__(self):
60 | return "({x},{y}) ({w}x{h})".format(x=self.x,y=self.y,w=self.w,h=self.h)
61 |
62 |
63 | class BlockPacker():
64 | """Packs blocks of varying sizes into a single, larger block"""
65 | def __init__(self):
66 | self.root = None
67 |
68 | def fit(self, blocks):
69 | nblocks = len(blocks)
70 | w = blocks[0].w# if nblocks > 0 else 0
71 | h = blocks[0].h# if nblocks > 0 else 0
72 |
73 | self.root = _BlockNode(0,0, w,h)
74 |
75 | for block in blocks:
76 | node = self.find_node(self.root, block.w, block.h)
77 | if node:
78 | # print("split")
79 | node_fit = self.split_node(node, block.w, block.h)
80 | block.x = node_fit.x
81 | block.y = node_fit.y
82 | else:
83 | # print("grow")
84 | node_fit = self.grow_node(block.w, block.h)
85 | block.x = node_fit.x
86 | block.y = node_fit.y
87 |
88 | def find_node(self, root, w, h):
89 | if root.used:
90 | # raise Exception("used")
91 | node = self.find_node(root.right, w, h)
92 | if node:
93 | return node
94 | return self.find_node(root.down, w, h)
95 | elif w <= root.w and h <= root.h:
96 | return root
97 | else:
98 | return None
99 |
100 | def split_node(self, node, w, h):
101 | node.used = True
102 | node.down = _BlockNode(
103 | node.x, node.y + h,
104 | node.w, node.h - h
105 | )
106 | node.right = _BlockNode(
107 | node.x + w, node.y,
108 | node.w - w, h
109 | )
110 | return node
111 |
112 | def grow_node(self, w, h):
113 | can_grow_down = w <= self.root.w
114 | can_grow_right = h <= self.root.h
115 |
116 | # try to keep the packing square
117 | should_grow_right = can_grow_right and self.root.h >= (self.root.w + w)
118 | should_grow_down = can_grow_down and self.root.w >= (self.root.h + h)
119 |
120 | if should_grow_right:
121 | return self.grow_right(w, h)
122 | elif should_grow_down:
123 | return self.grow_down(w, h)
124 | elif can_grow_right:
125 | return self.grow_right(w, h)
126 | elif can_grow_down:
127 | return self.grow_down(w, h)
128 | else:
129 | raise Exception("no valid expansion avaliable!")
130 |
131 | def grow_right(self, w, h):
132 | old_root = self.root
133 | self.root = _BlockNode(
134 | 0, 0,
135 | old_root.w + w, old_root.h,
136 | down=old_root,
137 | right=_BlockNode(self.root.w, 0, w, self.root.h),
138 | used=True
139 | )
140 |
141 | node = self.find_node(self.root, w, h)
142 | if node:
143 | return self.split_node(node, w, h)
144 | else:
145 | return None
146 |
147 | def grow_down(self, w, h):
148 | old_root = self.root
149 | self.root = _BlockNode(
150 | 0, 0,
151 | old_root.w, old_root.h + h,
152 | down=_BlockNode(0, self.root.h, self.root.w, h),
153 | right=old_root,
154 | used=True
155 | )
156 |
157 | node = self.find_node(self.root, w, h)
158 | if node:
159 | return self.split_node(node, w, h)
160 | else:
161 | return None
162 |
163 |
164 | def crop_by_extents(image, extent, tile=False, crop=False):
165 | image = image.convert("RGBA")
166 | # overlay = Image.new('RGBA', image.size, (255,255,255,0))
167 |
168 | w,h = image.size
169 | coords = [math.floor(extent.min_x*w), math.floor(extent.min_y*h),
170 | math.ceil(extent.max_x*w), math.ceil(extent.max_y*h)]
171 | # print("\nEXTENT")
172 | # pprint(extent)
173 |
174 | if min(extent.min_x,extent.min_y) < 0 or max(extent.max_x,extent.max_y) > 1:
175 | print("\tWARNING! UV Coordinates lying outside of [0:1] space!")
176 |
177 | # pprint(coords)
178 |
179 | if extent.to_tile:
180 | h_w, v_w = extent.tiling()
181 |
182 | new_im = Image.new("RGBA", (max(w,math.ceil(h_w*w)), max(h,math.ceil(v_w*h))))
183 | new_w, new_h = new_im.size
184 |
185 | # Iterate through a grid, to place the image to tile it
186 | for i in range(0, new_w, w):
187 | for j in range(0, new_h, h):
188 | new_im.paste(image, (i, j))
189 |
190 | crop_coords = coords.copy()
191 |
192 | if crop_coords[0] < 0:
193 | crop_coords[2] = crop_coords[2] - crop_coords[0]
194 | crop_coords[0] = 0
195 | if crop_coords[1] < 0:
196 | crop_coords[3] = crop_coords[3] - crop_coords[1]
197 | crop_coords[1] = 0
198 |
199 | # pprint(crop_coords)
200 | image = new_im.crop(crop_coords)
201 | else:
202 | coords[0] = max(coords[0], 0)
203 | coords[1] = max(coords[1], 0)
204 |
205 | coords[2] = min(coords[2], w)
206 | coords[3] = min(coords[3], h)
207 |
208 | image = image.crop(coords)
209 |
210 | changed_w = coords[2] - coords[0]
211 | changed_h = coords[3] - coords[1]
212 |
213 | # offset from origin x, y, horizontal scale, vertical scale
214 | # TODO: use an actual data structure to store this, not a bloody tuple
215 | changes = (coords[0], coords[1], changed_w/w, changed_h/h)
216 | # pprint(changes)
217 |
218 | return (image, changes)
219 |
220 | def pack_images(image_paths, background=(0,0,0,0), format="PNG", extents=None, tile=False, crop=False):
221 | images = []
222 | blocks = []
223 | image_name_map = {}
224 |
225 | image_paths = [path for path in image_paths if path is not None]
226 | image_paths.sort() # sort so we get repeatable file ordering, I hope!
227 | # pprint(image_paths)
228 |
229 | for filename in image_paths:
230 | print("opening", filename)
231 | image = Image.open(filename)
232 | # print(image.size)
233 |
234 | image = image.transpose(Image.FLIP_TOP_BOTTOM)
235 | # rescale images
236 | changes = None
237 | if extents and extents[filename]:
238 | # print(filename, extents)
239 | image, changes = crop_by_extents(image, extents[filename], tile, crop)
240 |
241 | images.append(image)
242 | image_name_map[filename] = image
243 |
244 | w,h = image.size
245 | # print(w,h, filename)
246 | # using filename so we can pass back UV info without storing it in image
247 | blocks.append(Block(w,h, data=(filename, changes)))
248 |
249 | # sort by width, descending (widest first)
250 | blocks.sort(key=lambda block: -block.w)
251 |
252 | packer = BlockPacker()
253 | packer.fit(blocks)
254 |
255 | output_image = Image.new("RGBA", (packer.root.w, packer.root.h))
256 |
257 | uv_changes = {}
258 | for block in blocks:
259 | # print(block)
260 | fname, changes = block.data
261 | image = image_name_map[fname]
262 | uv_changes[fname] = {
263 | "offset": (
264 | # should be in [0, 1] range
265 | (block.x - (changes[0] if changes else 0))/output_image.size[0],
266 | # UV origin is bottom left, PIL assumes top left!
267 | (block.y - (changes[1] if changes else 0))/output_image.size[1]
268 | ),
269 |
270 | "aspect": (
271 | ((1/changes[2]) if changes else 1) * (image.size[0]/output_image.size[0]),
272 | ((1/changes[3]) if changes else 1) * (image.size[1]/output_image.size[1])
273 | ),
274 | }
275 |
276 | output_image.paste(image, (block.x, block.y))
277 |
278 | output_image = output_image.transpose(Image.FLIP_TOP_BOTTOM)
279 | return output_image, uv_changes
280 |
--------------------------------------------------------------------------------
/imagepacker/objuvpacker.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/python
2 |
3 | # The MIT License (MIT)
4 |
5 | # Copyright (c) 2015 Luke Gaynor
6 |
7 | # Permission is hereby granted, free of charge, to any person obtaining a copy
8 | # of this software and associated documentation files (the "Software"), to deal
9 | # in the Software without restriction, including without limitation the rights
10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | # copies of the Software, and to permit persons to whom the Software is
12 | # furnished to do so, subject to the following conditions:
13 |
14 | # The above copyright notice and this permission notice shall be included in all
15 | # copies or substantial portions of the Software.
16 |
17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 | # SOFTWARE.
24 |
25 | import argparse
26 | import os
27 | import sys
28 | from pprint import pprint
29 | from distutils.util import strtobool
30 | from imagepacker import pack_images
31 |
32 | def guess_realpath(path):
33 | """Checks for a file in a path, or in a local path"""
34 | basename = os.path.basename(path)
35 |
36 | # first test the path
37 | if os.path.isfile(basename): # try locally
38 | return os.path.realpath(basename)
39 | elif os.path.isfile(path): # good, it exists
40 | return os.path.realpath(path)
41 | else:
42 | return None # uh oh
43 | pass
44 |
45 | def main():
46 | parser = argparse.ArgumentParser(description="Naively pokes obj+mtls")
47 | parser.add_argument("obj", help="path to the .obj file")
48 | parser.add_argument("-m", "--material", help="path to the .mtl file")
49 | parser.add_argument("-o","--output", help="output name, used for image and folder")
50 | parser.add_argument("-a","--add", nargs="+", help="any additional images to pack")
51 |
52 | parser.add_argument('--multi-mtl', action='store_true',help="preserve multiple materials, otherwise use one material with one texture by default")
53 | parser.add_argument('--no-crop', dest='crop', action='store_false', help="do not attempt to crop textures to just what is used")
54 | parser.add_argument('--no-tile', dest='tile', action='store_false', help="do not attempt to tile textures outside of UV space (must be cropping)")
55 | parser.add_argument('--no-wrap', dest='wrap', action='store_false', help="don't shift remaining UV verts into [0,1] space")
56 |
57 | parser.set_defaults(crop=True)
58 |
59 | args = parser.parse_args()
60 |
61 | additional = None
62 | if args.add:
63 | additional = [os.path.realpath(a) for a in args.add]
64 |
65 | # change to the obj's directory
66 | wdir = os.path.dirname(os.path.realpath(args.obj))
67 | os.chdir(wdir)
68 |
69 | # normalise names and paths
70 | obj_local_path = os.path.basename(args.obj)
71 | obj_name = os.path.splitext(os.path.basename(args.obj))[0]
72 |
73 | if args.output:
74 | output_name = args.output
75 | else: # default to a reasonable name
76 | output_name = obj_name + "_packed"
77 |
78 | # we read the entire obj both to check for mtl now, and for processing later
79 | obj_lines = []
80 | with open(obj_local_path, "r") as obj_file:
81 | obj_lines = [x.strip() for x in obj_file.readlines()]
82 |
83 | mtl = args.material
84 | if not mtl or not os.path.isfile(mtl): # got to find it ourselves!
85 | print("\ninvalid or missing mtl path!")
86 | for line in obj_lines:
87 | if line.startswith("mtllib"): # bingo, material in obj
88 | mtl = guess_realpath(line[7:])
89 | print("\ttrying path found in obj", mtl)
90 | break
91 |
92 | if not mtl or not os.path.isfile(mtl):
93 | print("cannot find mtl file!")
94 | # sys.exit(1)
95 | raise ValueError("cannot find mtl file!")
96 |
97 | print("opening material to determine diffuse textures")
98 | # establish material paths
99 | texmap = None
100 | names = []
101 | dmaps = []
102 |
103 | mtl_lines = []
104 | with open(mtl, "r") as mtl_file:
105 | mtl_lines = [x.strip() for x in mtl_file.readlines()]
106 |
107 | if mtl_lines[0].strip != "# Textures packed with a simple packer":
108 | mtl_lines.insert(0,"# Textures packed with a simple packer")
109 |
110 | new_mtl_lines = []
111 | outname = output_name+"_full.png"
112 | for line in mtl_lines:
113 | if line.startswith("newmtl"):
114 | name = line[7:]
115 | # print("\tsaw material name", name)
116 | if name and name != "None":
117 | if len(dmaps) != len(names):
118 | # print("\tlast material did not have a diffuse, ignoring")
119 | names.pop()
120 | names.append(name)
121 | else:
122 | # print("\tignoring 'None' material")
123 | continue
124 | elif line.startswith("map_"):
125 | mtype,m = line.split(" ", 1)
126 | if mtype.lower() == "map_kd":
127 | # dmap = guess_realpath(line[7:])
128 | dmap = guess_realpath(m)
129 | if not dmap:
130 | # raise ValueError("missing a required texture file " + line)
131 | print("\tmissing a required texture file " + line)
132 |
133 | # if dmap not in dmaps:
134 | dmaps.append(dmap)
135 | # print("\tsaw texture map", dmap)
136 | line = " ".join([mtype, outname])
137 | # line = "map_Kd " + outname
138 | else:
139 | # print("\tignoring non-diffuse texture map", mtype)
140 | continue
141 | elif line.startswith("d "):
142 | # print("\tignoring transparency value")
143 | continue
144 |
145 | new_mtl_lines.append(line)
146 |
147 | if len(dmaps) != len(names):
148 | # print("\tlast material did not have a diffuse, ignoring")
149 | names.pop()
150 |
151 | # process out a map of diffuse texture file paths
152 | assert(len(names) == len(dmaps))
153 | texmap = dict(zip(names, dmaps))
154 | print("\nmaterial name -> texture map:")
155 | pprint(texmap)
156 |
157 | # find what part (if not the entirety) of each diffuse that is used
158 | if args.crop:
159 | class AABB():
160 | def __init__(self, min_x=None, min_y=None, max_x=None, max_y=None):
161 | self.min_x = min_x
162 | self.min_y = min_y
163 | self.max_x = max_x
164 | self.max_y = max_y
165 |
166 | self.to_tile = False
167 |
168 | def add(self, x,y):
169 | self.min_x = min(self.min_x, x) if self.min_x is not None else x
170 | self.min_y = min(self.min_y, y) if self.min_y is not None else y
171 | self.max_x = max(self.max_x, x) if self.max_x is not None else x
172 | self.max_y = max(self.max_y, y) if self.max_y is not None else y
173 |
174 | def uv_wrap(self):
175 | return (self.max_x - self.min_x, self.max_y - self.min_y)
176 |
177 | def tiling(self):
178 | if self.min_x and self.max_x and self.min_y and self.max_y:
179 | if self.min_x < 0 or self.min_y < 0 or self.max_x > 1 or self.max_y > 1:
180 | return (self.max_x - self.min_x, self.max_y - self.min_y)
181 | return None
182 |
183 | def __repr__(self):
184 | return "({},{}) ({},{})".format(
185 | self.min_x,
186 | self.min_y,
187 | self.max_x,
188 | self.max_y
189 | )
190 | textents = {name: AABB() for name in set(dmaps)}
191 | # textents = dict(zip(names, AABB)) # hue
192 |
193 | uv_lines = []
194 | curr_mtl = None
195 | used_mtl = set()
196 |
197 | for line_idx, line in enumerate(obj_lines):
198 | if line.startswith("vt"):
199 | uv_lines.append(line_idx)
200 | elif line.startswith("usemtl"):
201 | mtl_name = line[7:]
202 | curr_mtl = mtl_name
203 | # print("changed to", curr_mtl)
204 | elif line.startswith("f"): # face definitions
205 | for vertex in line[2:].split(): # individual vertex definitions
206 | v_def = vertex.split(sep="/")
207 | if len(v_def) >= 2 and v_def[1]: # v or v/t or v/vt/vn or v//vn
208 | uv_idx = int(v_def[1]) - 1 # uv indexes start from 1
209 | uv_line_idx = uv_lines[uv_idx]
210 | uv_line = obj_lines[uv_line_idx][3:]
211 | uv = [float(uv.strip()) for uv in uv_line.split()]
212 |
213 | if curr_mtl and curr_mtl in texmap:
214 | used_mtl.add(mtl_name)
215 | textents[texmap[curr_mtl]].add(uv[0], uv[1])
216 | else:
217 | print(curr_mtl, "not in texmap")
218 | # get uv values at uv_idx
219 | # alter them in the original file
220 |
221 | # pprint(textents)
222 | # pprint(used_mtl)
223 |
224 | if args.tile:
225 | # loop through UV AABB's, warning when out of range and prompting
226 | # to see if the user wishes to tile the texture
227 |
228 | for name, extent in textents.items():
229 | print(name, extent)
230 | if extent.tiling():
231 | h_w, v_w = extent.tiling()
232 | if h_w > 1 or v_w > 1:
233 | print("\nWARNING: The following texture has coordinates that imply it tiles {}x{} times:\n\t{}".format(round(h_w, 1), round(v_w, 1), name))
234 | print("This may be intentional (i.e. tank track textures), or a sign of problematic UV coordinates.")
235 | print("Consider only unwrapping/tiling this if you know that it is intentional.")
236 | print("(If you are unsure, just hit enter to answer 'No')")
237 | to_tile = False
238 | try:
239 | to_tile = strtobool(input("Do you want to unroll this wrapping by tiling the texture? [y/N]: "))
240 | except ValueError as ve:
241 | pass
242 | extent.to_tile = to_tile
243 |
244 | if to_tile:
245 | print("Marking texture to be tiled.")
246 | else:
247 | print("Ignoring texture tiled.")
248 |
249 | else:
250 | textents = None
251 | # pprint(dmaps)
252 | # pack and save textures, get info about new coordinate changes
253 | print("\ncreating packed texture")
254 | if additional: # additional images
255 | print("adding additional images: " + ",".join(additional))
256 | dmaps.extend(additional)
257 | output_image, uv_changes = pack_images(list(set(dmaps)), extents=textents) # remove duplicates
258 | # output_image.show()
259 |
260 | uv_lines = []
261 | curr_mtl = None
262 |
263 | # apply changes to .obj UV's
264 | print("\napplying UV changes to obj")
265 | new_obj_lines = []
266 | for line_idx, line in enumerate(obj_lines):
267 | if line.startswith("vt"):
268 | uv_lines.append(line_idx)
269 | new_obj_lines.append(line)
270 | elif line.startswith("usemtl"):
271 | if curr_mtl is None or args.multi_mtl: # NEW!!!!
272 | mtl_name = line[7:]
273 | curr_mtl = mtl_name
274 | new_obj_lines.append(line)
275 | else:
276 | mtl_name = line[7:]
277 | curr_mtl = mtl_name
278 | new_obj_lines.append("\n")
279 | elif line.startswith("f"): # face definitions
280 | for vertex in line[2:].split(): # individual vertex definitions
281 | v_def = vertex.split(sep="/")
282 | if len(v_def) >= 2 and v_def[1]: # v or v/t or v/vt/vn or v//vn
283 | uv_idx = int(v_def[1]) - 1 # uv indexes start from 1
284 | uv_line_idx = uv_lines[uv_idx]
285 | uv_line = obj_lines[uv_line_idx][3:]
286 | uv = [float(uv.strip()) for uv in uv_line.split()]
287 |
288 | if curr_mtl and curr_mtl in texmap:
289 | changes = uv_changes[texmap[curr_mtl]]
290 | uv[0] = uv[0] * changes["aspect"][0] + changes["offset"][0]
291 | uv[1] = uv[1] * changes["aspect"][1] + changes["offset"][1]
292 |
293 |
294 | new_obj_lines[uv_line_idx] = "vt {0} {1}".format(
295 | uv[0], uv[1]
296 | # (uv[0] * changes["aspect"][0] + changes["offset"][0]),
297 | # (uv[1] * changes["aspect"][1] + changes["offset"][1])
298 | )
299 |
300 | # get uv values at uv_idx
301 | # alter them in the original file
302 | new_obj_lines.append(line)
303 | elif line.startswith("mtllib"): # change mtl file name!
304 | print("\tupdated obj's mtllib to",output_name+".mtl")
305 | new_obj_lines.append("mtllib " + output_name+".mtl")
306 | else:
307 | new_obj_lines.append(line)
308 |
309 | print("writing new obj, mtl and texture files to:")#, output_name+".obj", output_name+".mtl", outname)
310 | print("\t",os.path.realpath(output_name+".obj"))
311 | print("\t",os.path.realpath(output_name+".mtl"))
312 | print("\t",os.path.realpath(outname))
313 |
314 | if not os.path.exists(output_name):
315 | os.mkdir(output_name)
316 | # os.chdir(output_name)
317 |
318 | with open(output_name+".obj", "w") as new_obj:
319 | new_obj.write("\n".join(new_obj_lines))
320 | with open(output_name+".mtl", "w") as new_mtl:
321 | new_mtl.write("\n".join(new_mtl_lines))
322 | output_image.save(outname, format="PNG")
323 |
324 | print("\nRemember to convert the final packed texture into a JPEG if you do not need the transparency.")
325 |
326 | # output_image.show()
327 |
328 | if __name__ == '__main__':
329 | import traceback
330 | # import os
331 | # try:
332 | main()
333 | # except Exception as ex:
334 | # print("Uh oh!")
335 | # traceback.print_exc()
336 | # os.system('pause')
337 |
--------------------------------------------------------------------------------