diff --git a/__init__.py b/__init__.py index 6f7b63a..bb2ebef 100644 --- a/__init__.py +++ b/__init__.py @@ -1,11 +1,9 @@ -#__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] +import importlib +import inspect NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} -import importlib -import inspect - nodes_filename = "nodes" module = importlib.import_module(f".{nodes_filename}", package=__name__) for name, cls in inspect.getmembers(module, inspect.isclass): @@ -16,4 +14,6 @@ for name, cls in inspect.getmembers(module, inspect.isclass): disp = f"{name}" NODE_CLASS_MAPPINGS[node] = cls - NODE_DISPLAY_NAME_MAPPINGS[node] = disp \ No newline at end of file + NODE_DISPLAY_NAME_MAPPINGS[node] = disp + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file diff --git a/diff_rast/diff_mesh_renderer.py b/diff_rast/diff_mesh_renderer.py index 510dbd8..1267727 100644 --- a/diff_rast/diff_mesh_renderer.py +++ b/diff_rast/diff_mesh_renderer.py @@ -7,7 +7,7 @@ import torch.nn as nn import torch.nn.functional as F import nvdiffrast.torch as dr -from ..mesh_processer.mesh import Mesh, safe_normalize +from ..mesh_processer.mesh import safe_normalize def scale_img_nhwc(x, size, mag='bilinear', min='bilinear'): assert (x.shape[1] >= size[0] and x.shape[2] >= size[1]) or (x.shape[1] < size[0] and x.shape[2] < size[1]), "Trying to magnify image in one dimension and minify in the other" @@ -38,41 +38,41 @@ def make_divisible(x, m=8): return int(math.ceil(x / m) * m) class Renderer(nn.Module): - def __init__(self, opt): + def __init__(self, mesh, force_cuda_rast): super().__init__() - self.opt = opt + self.mesh = mesh - self.mesh = Mesh.load(self.opt.mesh, resize=False) - - if not self.opt.force_cuda_rast and (not self.opt.gui or os.name == 'nt'): - self.glctx = dr.RasterizeGLContext() - else: + if force_cuda_rast or os.name != 'nt': self.glctx = dr.RasterizeCudaContext() + else: + self.glctx = dr.RasterizeGLContext() # extract trainable parameters - self.v_offsets = nn.Parameter(torch.zeros_like(self.mesh.v)) - self.raw_albedo = nn.Parameter(trunc_rev_sigmoid(self.mesh.albedo)) + self.v_offsets = nn.Parameter(torch.zeros_like(self.mesh.v), requires_grad=True) + self.raw_albedo = nn.Parameter(trunc_rev_sigmoid(self.mesh.albedo), requires_grad=True) - def get_params(self): + def get_params(self, texture_lr, train_geo, geom_lr): params = [ - {'params': self.raw_albedo, 'lr': self.opt.texture_lr}, + {'params': self.raw_albedo, 'lr': texture_lr}, ] - if self.opt.train_geo: - params.append({'params': self.v_offsets, 'lr': self.opt.geom_lr}) + self.train_geo = train_geo + if train_geo: + params.append({'params': self.v_offsets, 'lr': geom_lr}) return params - @torch.no_grad() def export_mesh(self, save_path): - self.mesh.v = (self.mesh.v + self.v_offsets).detach() - self.mesh.albedo = torch.sigmoid(self.raw_albedo.detach()) self.mesh.write(save_path) + @torch.no_grad() + def update_mesh(self): + self.mesh.v = (self.mesh.v + self.v_offsets).detach() + self.mesh.albedo = torch.sigmoid(self.raw_albedo.detach()) def render(self, pose, proj, h0, w0, ssaa=1, bg_color=1, texture_filter='linear-mipmap-linear'): @@ -86,7 +86,7 @@ class Renderer(nn.Module): results = {} # get v - if self.opt.train_geo: + if self.train_geo: v = self.mesh.v + self.v_offsets # [N, 3] else: v = self.mesh.v @@ -106,9 +106,10 @@ class Renderer(nn.Module): texc, texc_db = dr.interpolate(self.mesh.vt.unsqueeze(0).contiguous(), rast, self.mesh.ft, rast_db=rast_db, diff_attrs='all') albedo = dr.texture(self.raw_albedo.unsqueeze(0), texc, uv_da=texc_db, filter_mode=texture_filter) # [1, H, W, 3] + print(f"albedo = dr.texture: {albedo.requires_grad}") albedo = torch.sigmoid(albedo) # get vn and render normal - if self.opt.train_geo: + if self.train_geo: i0, i1, i2 = self.mesh.f[:, 0].long(), self.mesh.f[:, 1].long(), self.mesh.f[:, 2].long() v0, v1, v2 = v[i0, :], v[i1, :], v[i2, :] @@ -133,6 +134,7 @@ class Renderer(nn.Module): # antialias albedo = dr.antialias(albedo, rast, v_clip, self.mesh.f).squeeze(0) # [H, W, 3] + print(f"albedo = dr.antialias: {albedo.requires_grad}") albedo = alpha * albedo + (1 - alpha) * bg_color # ssaa diff --git a/diff_rast/diff_texturing.py b/diff_rast/diff_texturing.py index 6b4f0a0..8c1e527 100644 --- a/diff_rast/diff_texturing.py +++ b/diff_rast/diff_texturing.py @@ -1,4 +1,135 @@ -import sys +import random -from ..shared_utils.common_utils import get_persistent_directory +import torch +import torch.nn.functional as F +import numpy as np +from pytorch_msssim import SSIM, MS_SSIM + +from ..diff_rast.diff_mesh_renderer import Renderer from ..shared_utils.camera_utils import orbit_camera, OrbitCamera + +class DiffTextureBaker: + + def __init__(self, reference_images, reference_masks, reference_orbit_camera_poses, reference_orbit_camera_fovy, mesh, + training_iterations, batch_size, texture_learning_rate, train_mesh_geometry, geometry_learning_rate, ms_ssim_loss_weight, force_cuda_rasterize): + self.device = torch.device("cuda") + + self.ref_imgs_num = len(reference_images) + + self.all_ref_cam_poses = reference_orbit_camera_poses + self.ref_cam_fovy = reference_orbit_camera_fovy + + self.ref_size_H = reference_images[0].shape[0] + self.ref_size_W = reference_images[0].shape[1] + + self.cam = OrbitCamera(self.ref_size_W, self.ref_size_H, fovy=reference_orbit_camera_fovy) + + # prepare main components for optimization + self.renderer = Renderer(mesh, force_cuda_rasterize).to(self.device) + + self.optimizer = torch.optim.Adam(self.renderer.get_params(texture_learning_rate, train_mesh_geometry, geometry_learning_rate)) + #self.ssim_loss = SSIM(data_range=1, size_average=True, channel=3) + self.ms_ssim_loss = MS_SSIM(data_range=1, size_average=True, channel=3) + self.lambda_ssim = ms_ssim_loss_weight + + self.training_iterations = training_iterations + + self.batch_size = batch_size + + # prepare reference images and masks + ref_imgs_torch_list = [] + ref_masks_torch_list = [] + for i in range(self.ref_imgs_num): + ref_imgs_torch_list.append(self.prepare_img(reference_images[i])) + ref_masks_torch_list.append(self.prepare_img(reference_masks[i].unsqueeze(2))) + + self.ref_imgs_torch = torch.cat(ref_imgs_torch_list, dim=0) + self.ref_masks_torch = torch.cat(ref_masks_torch_list, dim=0) + + def prepare_img(self, img): + img_new = img.permute(2, 0, 1).unsqueeze(0).to(self.device) + img_new = F.interpolate(img_new, (self.ref_size_H, self.ref_size_W), mode="bilinear", align_corners=False).contiguous() + return img_new + + def training(self): + starter = torch.cuda.Event(enable_timing=True) + ender = torch.cuda.Event(enable_timing=True) + starter.record() + + ref_imgs_masked = [] + for i in range(self.ref_imgs_num): + ref_imgs_masked.append((self.ref_imgs_torch[i] * self.ref_masks_torch[i]).unsqueeze(0)) + + ref_imgs_num_minus_1 = self.ref_imgs_num-1 + + for step in range(self.training_iterations): + + ### calculate loss between reference and rendered image from known view + loss = 0 + masked_rendered_img_batch = [] + masked_ref_img_batch = [] + for _ in range(self.batch_size): + + i = random.randint(0, ref_imgs_num_minus_1) + + radius, elevation, azimuth, center_X, center_Y, center_Z = self.all_ref_cam_poses[i] + + # render output + orbit_target = np.array([center_X, center_Y, center_Z], dtype=np.float32) + ref_pose = orbit_camera(elevation, azimuth, radius, target=orbit_target) + ref_cam = (ref_pose, self.cam.perspective) + out = self.renderer.render(*ref_cam, self.ref_size_H, self.ref_size_W, ssaa=1) #ssaa = min(2.0, max(0.125, 2 * np.random.random())) + + image = out["image"] # [H, W, 3] in [0, 1] + image = image.permute(2, 0, 1).contiguous() # [3, H, W] in [0, 1] + + #print(f"image.requires_grad: {image.requires_grad}") + + image_masked = (image * self.ref_masks_torch[i]).unsqueeze(0) + + #print(f"image_masked.requires_grad: {image_masked.requires_grad}") + #print(f"ref_imgs_masked[i].requires_grad: {ref_imgs_masked[i].requires_grad}") + + masked_rendered_img_batch.append(image_masked) + masked_ref_img_batch.append(ref_imgs_masked[i]) + + masked_rendered_img_batch_torch = torch.cat(masked_rendered_img_batch, dim=0) + masked_ref_img_batch_torch = torch.cat(masked_ref_img_batch, dim=0) + + # rgb loss + loss += (1 - self.lambda_ssim) * F.mse_loss(masked_rendered_img_batch_torch, masked_ref_img_batch_torch) + + # D-SSIM loss + # [1, 3, H, W] in [0, 1] + #loss += self.lambda_ssim * (1 - self.ssim_loss(X, Y)) + loss += self.lambda_ssim * (1 - self.ms_ssim_loss(masked_ref_img_batch_torch, masked_rendered_img_batch_torch)) + + print(f"masked_rendered_img_batch_torch.requires_grad: {masked_rendered_img_batch_torch.requires_grad}") + print(f"masked_ref_img_batch_torch.requires_grad: {masked_ref_img_batch_torch.requires_grad}") + + print(f"loss.requires_grad: {loss.requires_grad}") + + print(f"self.renderer.raw_albedo.requires_grad: {self.renderer.raw_albedo.requires_grad}") + + # import kiui + # kiui.lo(hor, ver) + # kiui.vis.plot_image(image) + + # optimize step + loss.backward() + self.optimizer.step() + self.optimizer.zero_grad() + + torch.cuda.synchronize() + + self.need_update = True + + print(f"Step: {step}") + + self.renderer.update_mesh() + + ender.record() + t = starter.elapsed_time(ender) + + def get_mesh_and_texture(self): + return (self.renderer.mesh, self.renderer.mesh.albedo, ) \ No newline at end of file diff --git a/mesh_processer/mesh.py b/mesh_processer/mesh.py index b58ba09..d19ec9f 100644 --- a/mesh_processer/mesh.py +++ b/mesh_processer/mesh.py @@ -49,7 +49,7 @@ class Mesh: if path is None: mesh = cls(**kwargs) # obj supports face uv - elif path.endswith(".obj"): + elif path.lower().endswith(".obj"): mesh = cls.load_obj(path, **kwargs) # trimesh only supports vertex uv, but can load more formats else: @@ -421,11 +421,11 @@ class Mesh: return self def write(self, path): - if path.endswith(".ply"): + if path.lower().endswith(".ply"): self.write_ply(path) - elif path.endswith(".obj"): + elif path.lower().endswith(".obj"): self.write_obj(path) - elif path.endswith(".glb") or path.endswith(".gltf"): + elif path.lower().endswith(".glb") or path.lower().endswith(".gltf"): self.write_glb(path) else: raise NotImplementedError(f"format {path} not supported!") diff --git a/nodes.py b/nodes.py index 82fc8bf..3c929b8 100644 --- a/nodes.py +++ b/nodes.py @@ -1,12 +1,9 @@ -import nodes -import torch -import torch.nn.functional as F -import numpy as np import os -import math +import re +import folder_paths as comfy_paths -from pytorch_msssim import SSIM, MS_SSIM +from .diff_rast.diff_texturing import DiffTextureBaker from .shared_utils.common_utils import cstr from .mesh_processer.mesh import Mesh @@ -18,7 +15,13 @@ MANIFEST = { "description": "An extensive node suite that enables ComfyUI to process 3D inputs (Mesh & UV Texture, etc) using cutting edge algorithms (3DGS, NeRF, etc.)", } -class Load_3D_File: +SUPPORTED_3D_EXTENSIONS = ( + '.obj', + '.ply', + '.glb', +) + +class Load_3D_Mesh: @classmethod def INPUT_TYPES(cls): @@ -38,27 +41,136 @@ class Load_3D_File: "mesh", ) FUNCTION = "load_mesh" - CATEGORY = "ComfyUI3D" - - SUPPORTED_3D_EXTENSIONS = ( - '.obj', - '.ply', - '.glb', - ) + CATEGORY = "ComfyUI3D/Import|Export" def load_mesh(self, mesh_file_path, resize, renormal, retex): mesh = None + if not os.path.isabs(mesh_file_path): + mesh_file_path = os.path.join(comfy_paths.input_directory, mesh_file_path) + if os.path.exists(mesh_file_path): - folder, file = os.path.split(self.filepath) - if file.lower().endswith(self.SUPPORTED_3D_EXTENSIONS): - mesh = Mesh.load(self.opt.mesh, resize, renormal, retex) + folder, filename = os.path.split(mesh_file_path) + if filename.lower().endswith(SUPPORTED_3D_EXTENSIONS): + mesh = Mesh.load(mesh_file_path, resize, renormal, retex) else: - cstr(f"File name {file} does not end with supported 3D file extensions: {self.SUPPORTED_3D_EXTENSIONS}").error.print() + cstr(f"[{self.__class__.__name__}] File name {filename} does not end with supported 3D file extensions: {SUPPORTED_3D_EXTENSIONS}").error.print() else: - cstr(f"File {mesh_file_path} does not exist").error.print() + cstr(f"[{self.__class__.__name__}] File {mesh_file_path} does not exist").error.print() return (mesh, ) + +class Save_3D_Mesh: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + "mesh_save_path": ("STRING", {"default": 'Mesh_[time(%Y-%m-%d)].obj', "multiline": False}), + }, + } + + OUTPUT_NODE = True + RETURN_TYPES = () + FUNCTION = "save_mesh" + CATEGORY = "ComfyUI3D/Import|Export" + + def save_mesh(self, mesh, mesh_save_path): + + mesh_folder_path, filename = os.path.split(mesh_save_path) + + if not os.path.isabs(mesh_save_path): + mesh_folder_path = os.path.join(comfy_paths.output_directory, mesh_folder_path) + mesh_save_path = os.path.join(mesh_folder_path, filename) + + os.makedirs(mesh_folder_path, exist_ok=True) + + if filename.lower().endswith(SUPPORTED_3D_EXTENSIONS): + mesh.export_mesh(mesh_save_path) + cstr(f"[{self.__class__.__name__}] saved model to {mesh_save_path}").msg.print() + else: + cstr(f"File name {filename} does not end with supported 3D file extensions: {SUPPORTED_3D_EXTENSIONS}").error.print() + + return {"ui": {"mesh": mesh}} + +class Generate_Orbit_Camera_Poses: + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "reference_images": ("IMAGE",), + "generate_pose_command": ("STRING", { + "default": "#([start_reference_image_index : end_reference_image_index], orbit_radius, elevation_angle [-90, 90], start_azimuth_angle [0, 360], end_azimuth_angle [0, 360])\n([0:30], 1.75, 0, 0, 360)", + "multiline": True + }), + }, + } + + OUTPUT_NODE = True + RETURN_TYPES = ( + "ORBIT_CAMPOSES", # (orbit radius, elevation, azimuth, orbit center X, orbit center Y, orbit center Z) + ) + RETURN_NAMES = ( + "orbit_camposes", + ) + FUNCTION = "get_camposes" + CATEGORY = "ComfyUI3D/Preprocessor" + + class Slice_Camposes: + def __init__(self, start_reference_image_index, end_reference_image_index, camposes_start_to_end): + self.start_reference_image_index = start_reference_image_index + self.end_reference_image_index = end_reference_image_index + self.camposes_start_to_end = camposes_start_to_end + + def get_camposes(self, reference_images, generate_pose_command): + orbit_camposes = [] + + self.ref_imgs_num_minus_1 = len(reference_images) - 1 + + # To match pattern ( [ start_reference_image_index : end_reference_image_index ] , orbit_radius, elevation_angle , start_azimuth_angle , end_azimuth_angle ) + pattern = re.compile(r"\([ \t]*\[[ \t]*(\d+)[ \t]*:[ \t]*(\d+)[ \t]*\][ \t]*,[ \t]*([\d]+\.?[\d]*)[ \t]*,[ \t]*([\d]+\.?[\d]*)[ \t]*,[ \t]*([\d]+\.?[\d]*)[ \t]*,[ \t]*([\d]+\.?[\d]*)[ \t]*\)") + all_matches = pattern.findall(generate_pose_command) + + all_slice_camposes = [] + for match in all_matches: + start_reference_image_index, end_reference_image_index, orbit_radius, elevation_angle, start_azimuth_angle, end_azimuth_angle = (int(s) if i < 2 else float(s) for i, s in enumerate(match)) + + end_reference_image_index = min(end_reference_image_index, self.ref_imgs_num_minus_1) + + if start_reference_image_index <= end_reference_image_index: + + azimuth_imgs_num = end_reference_image_index - start_reference_image_index + 1 + # calculate all the reference camera azimuth angles + camposes_start_to_end = [] + if start_azimuth_angle > end_azimuth_angle: + azimuth_angle_interval = -(end_azimuth_angle + 360 - start_azimuth_angle) / azimuth_imgs_num + else: + azimuth_angle_interval = (end_azimuth_angle - start_azimuth_angle) / azimuth_imgs_num + + now_azimuth_angle = start_azimuth_angle + for _ in range(azimuth_imgs_num): + camposes_start_to_end.append((orbit_radius, elevation_angle, now_azimuth_angle, 0.0, 0.0, 0.0)) + now_azimuth_angle = (now_azimuth_angle + azimuth_angle_interval) % 360 + + all_slice_camposes.append(Generate_Orbit_Camera_Poses.Slice_Camposes(start_reference_image_index, end_reference_image_index, camposes_start_to_end)) + + else: + cstr(f"[{self.__class__.__name__}] start_reference_image_index: {start_reference_image_index} must smaller than or equal to end_reference_image_index: {end_reference_image_index}").error.print() + + all_slice_camposes = sorted(all_slice_camposes, key=lambda slice_camposes:slice_camposes.start_reference_image_index) + + last_end_index_plus_1 = 0 + for slice_camposes in all_slice_camposes: + if last_end_index_plus_1 == slice_camposes.start_reference_image_index: + orbit_camposes.extend(slice_camposes.camposes_start_to_end) + last_end_index_plus_1 = slice_camposes.end_reference_image_index + 1 + else: + orbit_camposes = [] + cstr(f"[{self.__class__.__name__}] Last end_reference_image_index: {end_reference_image_index} plus 1 must equal to current start_reference_image_index: {start_reference_image_index}").error.print() + + return (orbit_camposes, ) + class Gaussian_Splatting: @classmethod @@ -76,7 +188,7 @@ class Gaussian_Splatting: "raw_3DGS", ) FUNCTION = "run_3DGS" - CATEGORY = "ComfyUI3D" + CATEGORY = "ComfyUI3D/Algorithm" def run_3DGS(self, reference_images): raw_3DGS = None @@ -85,25 +197,66 @@ class Gaussian_Splatting: class Bake_Texture_To_Mesh: + def __init__(self): + self.need_update = False + @classmethod def INPUT_TYPES(cls): return { "required": { "reference_images": ("IMAGE",), - "mesh": ("MESH") + "reference_masks": ("MASK",), + "reference_orbit_camera_poses": ("ORBIT_CAMPOSES",), # (orbit radius, elevation, azimuth, orbit center X, orbit center Y, orbit center Z) + "reference_orbit_camera_fovy": ("FLOAT", {"default": 49.1, "min": 0.0, "max": 180.0, "step": 0.1}), + "mesh": ("MESH",), + "training_iterations": ("INT", {"default": 1000, "min": 1, "max": 100000}), + "batch_size": ("INT", {"default": 5, "min": 1, "max": 0xffffffffffffffff}), + "texture_learning_rate": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + "train_mesh_geometry": ("BOOLEAN", {"default": False},), + "geometry_learning_rate": ("FLOAT", {"default": 0.01, "min": 0.0, "max": 0.1, "step": 0.001}), + "ms_ssim_loss_weight": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "force_cuda_rasterize": ("BOOLEAN", {"default": False},), }, } RETURN_TYPES = ( + "MESH", "IMAGE", ) RETURN_NAMES = ( - "baked_texture", + "trained_mesh", + "baked_texture", # [1, H, W, 3] ) FUNCTION = "bake_texture" - CATEGORY = "ComfyUI3D" + CATEGORY = "ComfyUI3D/Algorithm" - def bake_texture(self, reference_images, mesh): + def bake_texture(self, reference_images, reference_masks, reference_orbit_camera_poses, reference_orbit_camera_fovy, mesh, + training_iterations, batch_size, texture_learning_rate, train_mesh_geometry, geometry_learning_rate, ms_ssim_loss_weight, force_cuda_rasterize): + + trained_mesh = None baked_texture = None - - return (baked_texture, ) \ No newline at end of file + + ref_imgs_num = len(reference_images) + ref_masks_num = len(reference_masks) + if ref_imgs_num == ref_masks_num: + + ref_cam_poses_num = len(reference_orbit_camera_poses) + if ref_imgs_num == ref_cam_poses_num: + + if batch_size > ref_imgs_num: + cstr(f"[{self.__class__.__name__}] Batch size {batch_size} is bigger than number of reference images {ref_imgs_num}! Set batch size to {ref_imgs_num} instead").warning.print() + batch_size = ref_imgs_num + + texture_baker = DiffTextureBaker(reference_images, reference_masks, reference_orbit_camera_poses, reference_orbit_camera_fovy, mesh, + training_iterations, batch_size, texture_learning_rate, train_mesh_geometry, geometry_learning_rate, ms_ssim_loss_weight, force_cuda_rasterize) + + texture_baker.training() + + trained_mesh, baked_texture = texture_baker.get_mesh_and_texture() + + else: + cstr(f"[{self.__class__.__name__}] Number of reference images {ref_imgs_num} does not equal to number of reference camera poses {ref_cam_poses_num}").error.print() + else: + cstr(f"[{self.__class__.__name__}] Number of reference images {ref_imgs_num} does not equal to number of masks {ref_masks_num}").error.print() + + return (trained_mesh, baked_texture, ) \ No newline at end of file diff --git a/shared_utils/common_utils.py b/shared_utils/common_utils.py index 5d447dc..6e3bb7f 100644 --- a/shared_utils/common_utils.py +++ b/shared_utils/common_utils.py @@ -76,9 +76,9 @@ class cstr(str): print(self, **kwargs) #! MESSAGE TEMPLATES -cstr.color.add_code("msg", f"{cstr.color.BLUE}WAS Node Suite: {cstr.color.END}") -cstr.color.add_code("warning", f"{cstr.color.BLUE}WAS Node Suite {cstr.color.LIGHTYELLOW}[WARNING] {cstr.color.END}") -cstr.color.add_code("error", f"{cstr.color.RED}WAS Node Suite {cstr.color.END}[ERROR] {cstr.color.END}") +cstr.color.add_code("msg", f"{cstr.color.BLUE}[ComfyUI-3D] {cstr.color.END}") +cstr.color.add_code("warning", f"{cstr.color.LIGHTYELLOW}[ComfyUI-3D] [WARNING] {cstr.color.END}") +cstr.color.add_code("error", f"{cstr.color.RED}[ComfyUI-3D] [ERROR] {cstr.color.END}") def get_persistent_directory(folder_name): if sys.platform == "win32":