diff --git a/other/generate_gifs/__init__.py b/other/generate_gifs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/other/generate_gifs/gif_processing.ipynb b/other/generate_gifs/gif_processing.ipynb index b049a0de..ee6bc1a8 100644 --- a/other/generate_gifs/gif_processing.ipynb +++ b/other/generate_gifs/gif_processing.ipynb @@ -184,7 +184,7 @@ "metadata": {}, "cell_type": "code", "source": [ - "from other.media_generation.utils.font_manager import FontManager\n", + "from other.generate_gifs.utils.font_manager import FontManager\n", "\n", "# Initialize font manager and list available fonts\n", "font_manager = FontManager()\n", @@ -243,7 +243,7 @@ "metadata": {}, "cell_type": "code", "source": [ - "from other.media_generation.utils.gif_processor import GIFProcessor\n", + "from other.generate_gifs.utils.gif_processor import GIFProcessor\n", "\n", "\n", "def process_all_gifs():\n", diff --git a/other/media_generation/README.md b/other/media_generation/README.md new file mode 100644 index 00000000..e477af2e --- /dev/null +++ b/other/media_generation/README.md @@ -0,0 +1,38 @@ +# GIFs generation with Materials visualization + +## 1. Setup materials + +1.1. Generate materials in Mat3ra JSON format with [Materials Designer](https://materials-designer.mat3ra.com/) or use a script. + +1.2. Place materials JSON files in the `uploads` directory. + +1.3. Name them with a short name that will appear at bottom left of GIF. + +Alternatively, you can use `create_materials` notebook that has functionality for random generation of some types of materials. Since it's random, verification of the generated materials is necessary. + +## 2. Start wave.js + +2.1. Run wave.js (from PR: https://github.com/Exabyte-io/wave.js/pull/165) locally (default port 3002 -- used in notebooks). + +Or keep URL of the Netlify deployment in the notebook. + +## 3. Record material GIFs + +3.1. Run `record_gifs_with_wave.ipynb` to generate GIFs. + +This notebook will generate GIFs for all JSON materials in the `input` directory and save them in the `output` directory. + +3.2. Wait until the GIFs are downloaded. They will appear at the top level of the repo, because we can't control the saving directory. But the next notebook will automatically move them to the `output` directory. + +## 4. Add overlays and generate final GIFs + +4.1. Store any media files (e.g. images, videos) you want to overlay on the GIFs in the `assets` directory. + +4.2. Run `gif_processing_multiple.ipynb` to add overlays and generate final GIFs. + +This notebook will move the GIFs from the top level to the `output` directory, removing any duplications (judging by the file name), and add overlays with the material names. + + +## Single GIF processing + +If you created a GIF with Materials Designer manually, you can use the `gif_processing_single.ipynb` notebook to add overlays and generate the final GIF. diff --git a/other/media_generation/create_materials.ipynb b/other/media_generation/create_materials.ipynb new file mode 100644 index 00000000..01f0ea2b --- /dev/null +++ b/other/media_generation/create_materials.ipynb @@ -0,0 +1,387 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "# Generation of random materials\n", + "\n", + "Run first few cells to set up definitions.\n", + "\n", + "Setup and run selected cells such as \"Create interfaces\" and \"Generate point defects\" to generate desired materials." + ], + "id": "1ff9a40df2ca55a7" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "import json\n", + "import re\n", + "import os\n", + "import random\n", + "from pymatgen.ext.matproj import MPRester\n", + "from mat3ra.made.tools.build.slab import SlabConfiguration, get_terminations, create_slab\n", + "from mat3ra.made.tools.build.interface import InterfaceConfiguration, ZSLStrainMatchingParameters, \\\n", + " ZSLStrainMatchingInterfaceBuilder, ZSLStrainMatchingInterfaceBuilderParameters\n", + "from utils.visualize import visualize_materials\n", + "from utils.jupyterlite import set_materials\n", + "\n", + "from mat3ra.standata.materials import Materials\n", + "from mat3ra.made.material import Material\n", + "from mat3ra.made.tools.modify import wrap_to_unit_cell\n", + "\n", + "materials = Materials.get_by_categories(\"3D\")\n", + "\n", + "# Load the symbols from the JSON file\n", + "with open('symbols_map.json', 'r') as file:\n", + " symbols = json.load(file)\n", + "\n", + "# Extract the \"OVER\" symbol\n", + "SLASH_SYMBOL = symbols[\"/\"]\n", + "\n", + "\n", + "def get_random_miller_index(lattice_type):\n", + " \"\"\"\n", + " Get a random Miller index for a given lattice type with a probability distribution\n", + " \"\"\"\n", + " lattice_to_miller = {\n", + " \"CUB\": [{(1, 1, 1): 0.5}, {(1, 1, 0): 0.4}, {(1, 0, 0): 0.1}],\n", + " \"FCC\": [{(1, 1, 1): 0.5}, {(1, 1, 0): 0.3}, {(0, 0, 1): 0.2}],\n", + " \"HEX\": [{(0, 0, 1): 0.9}, {(1, 1, 1): 0.1}],\n", + " \"TRI\": [{(1, 1, 1): 0.5}, {(1, 1, 0): 0.3}, {(0, 0, 1): 0.2}],\n", + " \"_\": (0, 0, 1)\n", + " }\n", + "\n", + " if lattice_type not in lattice_to_miller:\n", + " return lattice_to_miller[\"_\"]\n", + "\n", + " miller_indices = lattice_to_miller[lattice_type]\n", + "\n", + " if isinstance(miller_indices, list):\n", + " choices, weights = zip(*[(k, v) for d in miller_indices for k, v in d.items()])\n", + " return random.choices(choices, weights=weights, k=1)[0]\n", + " else:\n", + " return miller_indices\n", + "\n", + "\n", + "def generate_interface(substrate_json, film_json):\n", + " print(f\"Creating interface: {substrate_json['name']} and {film_json['name']}\")\n", + "\n", + " substrate = Material.create(substrate_json)\n", + " film = Material.create(film_json)\n", + " substrate_dimensionality = \"2D\" if \"2D\" in substrate.name else \"3D\"\n", + " film_dimensionality = \"2D\" if \"2D\" in film.name else \"3D\"\n", + "\n", + " substrate_miller_indices = get_random_miller_index(substrate.lattice.type.name)\n", + " film_miller_indices = get_random_miller_index(film.lattice.type.name)\n", + "\n", + " # Get material names before the \",\" -- the formula\n", + " substrate_name = re.match(r'[^,]*', substrate.name).group(0) + str(substrate_miller_indices).replace(\", \", \"\")\n", + " film_name = re.match(r'[^,]*', film.name).group(0) + str(film_miller_indices).replace(\", \", \"\")\n", + "\n", + " interface_name = f\"{film_name}{SLASH_SYMBOL}{substrate_name}\"\n", + " print(f\"Interface name: {interface_name}\")\n", + "\n", + " # Define slab and interface parameters\n", + " film_params = {\n", + " \"miller_indices\": film_miller_indices,\n", + " \"thickness\": 1 if film_dimensionality == \"2D\" else 3,\n", + " \"vacuum\": 15.0,\n", + " \"xy_supercell_matrix\": [[1, 0], [0, 1]],\n", + " \"use_conventional_cell\": True,\n", + " \"use_orthogonal_z\": True\n", + " }\n", + "\n", + " substrate_params = {\n", + " \"miller_indices\": substrate_miller_indices,\n", + " \"thickness\": 1 if substrate_dimensionality == \"2D\" else 3,\n", + " \"vacuum\": 15.0,\n", + " \"xy_supercell_matrix\": [[1, 0], [0, 1]],\n", + " \"use_conventional_cell\": True,\n", + " \"use_orthogonal_z\": True\n", + " }\n", + "\n", + " interface_params = {\n", + " \"distance_z\": 3.0,\n", + " \"vacuum\": 3.0,\n", + " \"max_area\": 150,\n", + " \"max_area_tol\": 0.10,\n", + " \"max_angle_tol\": 0.04,\n", + " \"max_length_tol\": 0.04\n", + " }\n", + "\n", + " # Create slab configurations\n", + " substrate_slab_config = SlabConfiguration(bulk=substrate, **substrate_params)\n", + " film_slab_config = SlabConfiguration(bulk=film, **film_params)\n", + " try:\n", + " # Get terminations\n", + " substrate_terminations = get_terminations(substrate_slab_config)\n", + " film_terminations = get_terminations(film_slab_config)\n", + "\n", + " # Create slabs\n", + " # substrate_slabs = [create_slab(substrate_slab_config, t) for t in substrate_terminations]\n", + " # film_slabs = [create_slab(film_slab_config, t) for t in film_terminations]\n", + "\n", + " # Select termination pair (example: first pair)\n", + " termination_pair = (film_terminations[0], substrate_terminations[0])\n", + "\n", + " # Create interface configuration\n", + " interface_config = InterfaceConfiguration(\n", + " film_configuration=film_slab_config,\n", + " substrate_configuration=substrate_slab_config,\n", + " film_termination=termination_pair[0],\n", + " substrate_termination=termination_pair[1],\n", + " distance_z=interface_params[\"distance_z\"],\n", + " vacuum=interface_params[\"vacuum\"]\n", + " )\n", + "\n", + " # Set strain matching parameters\n", + " zsl_params = ZSLStrainMatchingParameters(\n", + " max_area=interface_params[\"max_area\"],\n", + " max_area_ratio_tol=interface_params[\"max_area_tol\"],\n", + " max_angle_tol=interface_params[\"max_angle_tol\"],\n", + " max_length_tol=interface_params[\"max_length_tol\"]\n", + " )\n", + "\n", + " # Generate interfaces\n", + " builder = ZSLStrainMatchingInterfaceBuilder(\n", + " build_parameters=ZSLStrainMatchingInterfaceBuilderParameters(strain_matching_parameters=zsl_params)\n", + " )\n", + " interfaces = builder.get_materials(configuration=interface_config)\n", + "\n", + " # Visualize and save the interfaces\n", + " interface = interfaces[0]\n", + " interface = wrap_to_unit_cell(interface)\n", + " visualize_materials([{\"material\": interface, \"rotation\": \"-90x\"}, {\"material\": interface}])\n", + "\n", + " interface.name = interface_name\n", + " set_materials(interface)\n", + " except Exception as e:\n", + " print(f\"Error creating interface between {substrate.name} and {film.name}: {e}\")\n" + ], + "id": "f0c47155aa2a5343", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Generate point defects", + "id": "10a3ac072b69fa38" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "from mat3ra.made.material import Material\n", + "from mat3ra.made.tools.build.supercell import create_supercell\n", + "from mat3ra.made.tools.build.defect import PointDefectConfiguration, create_defects\n", + "from mat3ra.made.tools.build.defect.builders import PointDefectBuilderParameters\n", + "import random\n", + "\n", + "# Define common substitutions and interstitials\n", + "COMMON_DOPANTS = {\n", + " \"Ni\": [\"Cu\", \"Fe\", \"Co\"],\n", + " \"Si\": [\"Ge\", \"C\"],\n", + " \"Ti\": [\"V\", \"Zr\"],\n", + " \"Al\": [\"Ga\", \"Mg\"],\n", + " \"Ga\": [\"Mn\", \"In\"],\n", + " \"In\": [\"Sn\", \"Sb\"],\n", + " \"Sn\": [\"Pb\", \"Bi\"],\n", + " \"Nb\": [\"Ta\", \"Zr\"],\n", + " \"Mo\": [\"W\", \"Cr\"],\n", + " \"W\": [\"Re\", \"Os\"],\n", + " \"Fe\": [\"Co\", \"Ni\"],\n", + " \"Co\": [\"Ni\", \"Cu\"],\n", + " \"Cu\": [\"Ag\", \"Au\"],\n", + " \"Zn\": [\"Cd\", \"Hg\"],\n", + " \"Mg\": [\"Ca\", \"Sr\"],\n", + " \"Ca\": [\"Ba\", \"Sr\"],\n", + " \"Sr\": [\"Ba\", \"Ra\"],\n", + " \"Ba\": [\"Ra\", \"Pb\"],\n", + "}\n", + "\n", + "COMMON_INTERSTITIALS = [\"B\", \"C\", \"N\", \"O\"]\n", + "\n", + "\n", + "def get_substitution_candidates(base_elements):\n", + " candidates = set()\n", + " for el in base_elements:\n", + " candidates.update(COMMON_DOPANTS.get(el, []))\n", + " return list(candidates) or COMMON_INTERSTITIALS\n", + "\n", + "\n", + "def get_supercell_matrix(material):\n", + " if material.lattice.type == \"HEX\":\n", + " return [[3, 0, 0], [0, 3, 0], [0, 0, 1]]\n", + " if material.lattice.a < 3.0:\n", + " return [[3, 0, 0], [0, 3, 0], [0, 0, 3]]\n", + " elif material.lattice.a < 6.0:\n", + " return [[2, 0, 0], [0, 2, 0], [0, 0, 2]]\n", + " else:\n", + " return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]\n", + "\n", + "\n", + "def generate_point_defects(material_json, mode=\"random\", num_defects=None):\n", + " material = Material.create(material_json)\n", + " base_elements = list(set(material.basis.elements.values))\n", + " substitution_elements = get_substitution_candidates(base_elements)\n", + " interstitial_elements = COMMON_INTERSTITIALS\n", + "\n", + " supercell: Material = create_supercell(material, supercell_matrix=get_supercell_matrix(material))\n", + " if num_defects is None:\n", + " num_elements = len(supercell.basis.elements.values)\n", + " num_defects = min(num_elements // 5, 10) # Limit to 20% of the number of elements\n", + " defect_configs = []\n", + "\n", + " if mode == \"grouped_substitution\":\n", + " dopant = random.choice(substitution_elements)\n", + " target_atoms = random.sample(supercell.basis.elements.to_array_of_values_with_ids(), num_defects)\n", + " for atom in target_atoms:\n", + " defect_configs.append({\n", + " \"defect_type\": \"substitution\",\n", + " \"site_id\": atom.id,\n", + " \"chemical_element\": dopant,\n", + " })\n", + "\n", + " elif mode == \"frenkel_cluster\":\n", + " element = random.choice(base_elements)\n", + " atoms_of_element = supercell.basis.elements.get_elements_by_value(element)\n", + "\n", + " if len(atoms_of_element) < num_defects:\n", + " num_defects = len(atoms_of_element)\n", + "\n", + " for atom in random.sample(atoms_of_element, num_defects):\n", + " defect_configs.append({\n", + " \"defect_type\": \"vacancy\",\n", + " \"site_id\": atom.id,\n", + " })\n", + " defect_configs.append({\n", + " \"defect_type\": \"interstitial\",\n", + " \"approximate_coordinate\": [random.uniform(0, 1) for _ in range(3)],\n", + " \"chemical_element\": element,\n", + " })\n", + "\n", + " else: # random mode\n", + " for _ in range(num_defects):\n", + " defect_type = random.choice([\"vacancy\", \"interstitial\", \"substitution\"])\n", + " if defect_type == \"vacancy\":\n", + " site = random.choice(supercell.basis.coordinates.to_array_of_values_with_ids())\n", + " defect_configs.append({\n", + " \"defect_type\": \"vacancy\",\n", + " \"site_id\": site.id,\n", + " })\n", + " elif defect_type == \"interstitial\":\n", + " defect_configs.append({\n", + " \"defect_type\": \"voronoi_interstitial\",\n", + " \"approximate_coordinate\": [random.uniform(0, 1) for _ in range(3)],\n", + " \"chemical_element\": random.choice(interstitial_elements),\n", + " })\n", + " elif defect_type == \"substitution\":\n", + " site = random.choice(supercell.basis.coordinates.to_array_of_values_with_ids())\n", + " defect_configs.append({\n", + " \"defect_type\": \"substitution\",\n", + " \"site_id\": site.id,\n", + " \"chemical_element\": random.choice(substitution_elements),\n", + " })\n", + "\n", + " configurations = [PointDefectConfiguration.from_dict(supercell, d) for d in defect_configs]\n", + " builder_parameters = PointDefectBuilderParameters(center_defect=False)\n", + "\n", + " return create_defects(builder_parameters=builder_parameters, configurations=configurations)\n" + ], + "id": "409ece78e162a33d", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Generate interfaces between all pairs of materials", + "id": "2d27d1cce04b6bc4" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "# Create interfaces\n", + "# for i, substrate_json in enumerate(materials):\n", + "# for j, film_json in enumerate(materials):\n", + "# if i != j:\n", + "# generate_interface(substrate_json, film_json)\n" + ], + "id": "82f16d004d364af3", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Generate interfaces between random pairs of materials", + "id": "8c63d6310b33335c" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "import random\n", + "\n", + "num_pairs = 20\n", + "\n", + "for _ in range(num_pairs):\n", + " substrate_json, film_json = random.sample(materials, 2)\n", + " generate_interface(substrate_json, film_json)" + ], + "id": "3572ccacbb4fd25f", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Generate point defects in random materials", + "id": "73d4d8d67f9e92b4" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "import random\n", + "\n", + "num_materials = 5\n", + "num_defects = None\n", + "random_materials = random.sample(materials, num_materials)\n", + "\n", + "for material_json in random_materials:\n", + " try:\n", + " material_with_defect = generate_point_defects(material_json, mode=\"random\", num_defects=num_defects)\n", + " visualize_materials(\n", + " [{\"material\": material_with_defect}, {\"material\": material_with_defect, \"rotation\": \"-90x,60y\"}])\n", + " set_materials(material_with_defect)\n", + " except Exception as e:\n", + " print(f\"Error generating defects for material {material_json['name']}: {e}\")" + ], + "id": "36307e16f113f5e8", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "", + "id": "173593e52d53c683", + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "name": "python3", + "language": "python", + "display_name": "Python 3 (ipykernel)" + } + }, + "nbformat": 5, + "nbformat_minor": 9 +} diff --git a/other/media_generation/gif_processing.ipynb b/other/media_generation/gif_processing.ipynb new file mode 100644 index 00000000..a3595f6f --- /dev/null +++ b/other/media_generation/gif_processing.ipynb @@ -0,0 +1,424 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# GIF Processing Notebook\n", + "\n", + "This notebook provides functionality to:\n", + "1. Resize GIFs to specified dimensions\n", + "2. Add text overlay at selected positions\n", + "3. Add image overlay at selected positions\n", + "4. Compress the resulting GIF\n", + "\n", + "## 1. Import of Libraries." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import re\n", + "\n", + "# Import required libraries\n", + "from PIL import Image, ImageDraw, ImageFont\n", + "import os\n", + "from IPython.display import display, Image as IPImage\n", + "import numpy as np" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 2. Input/Output Settings" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "INPUT_DIR = \"input\"\n", + "OUTPUT_DIR = \"output\"\n", + "ASSETS_DIR = \"assets\"\n", + "\n", + "# Create directories if they don't exist\n", + "for directory in [INPUT_DIR, OUTPUT_DIR, ASSETS_DIR]:\n", + " os.makedirs(directory, exist_ok=True)\n", + "\n", + "# GIF Settings\n", + "INPUT_GIF = \"MoS2_O4V2.gif\" # Place this in files/input/\n", + "OUTPUT_GIF = \"output.gif\" # Will be saved in files/output/\n", + "GIF_SIZE = 600 # Size for square output\n", + "QUALITY = 20 # GIF quality (1-100)\n", + "\n", + "# Logo Settings\n", + "LOGO_FILE = \"logo.png\" # Place your logo in files/assets/\n", + "LOGO_POSITION = (15, 10)\n", + "\n", + "# Text Overlay Settings\n", + "FONT_SIZE = 16\n", + "WHITE_COLOR = (255, 255, 255)\n", + "BLACK_COLOR = (0, 0, 0)\n", + "FONT_NAME = \"lucida-grande\"\n", + "TEXT_OVERLAYS = [\n", + " {\n", + " \"text\": \"Material ABC\",\n", + " \"position\": (10, GIF_SIZE - 10 - FONT_SIZE), # Bottom left\n", + " \"font\": FONT_NAME,\n", + " \"color\": WHITE_COLOR,\n", + " \"stroke_width\": 2,\n", + " \"stroke_fill\": BLACK_COLOR\n", + " },\n", + " {\n", + " \"text\": \"Available in our materials bank\",\n", + " \"position\": (GIF_SIZE//2 + 50, GIF_SIZE - 10 - FONT_SIZE), # Bottom right\n", + " \"font\": FONT_NAME,\n", + " \"color\": WHITE_COLOR,\n", + " \"stroke_width\": 2,\n", + " \"stroke_fill\": BLACK_COLOR\n", + " }\n", + "]\n" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 3. Font Manager class" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import matplotlib.font_manager as fm\n", + "class FontManager:\n", + " \"\"\"Manages fonts for the GIF processor\"\"\"\n", + "\n", + " def __init__(self):\n", + " \"\"\"Initialize font manager and discover available fonts\"\"\"\n", + " self.fonts = self._discover_fonts()\n", + "\n", + " def _discover_fonts(self):\n", + " \"\"\"Discover all available fonts in the system\"\"\"\n", + " fonts = {}\n", + "\n", + " # Get all font paths from matplotlib font manager\n", + " for font in fm.fontManager.ttflist:\n", + " try:\n", + " # Create a normalized name (lowercase, no spaces)\n", + " name = font.name.lower().replace(' ', '-')\n", + " # Store the font path\n", + " fonts[name] = font.fname\n", + " except Exception as e:\n", + " continue\n", + "\n", + " return fonts\n", + "\n", + " def get_font(self, font_name='default', size=30):\n", + " \"\"\"Get a font by name and size\"\"\"\n", + " try:\n", + " # Handle default font\n", + " if font_name == 'default':\n", + " return ImageFont.load_default()\n", + "\n", + " # Try exact match\n", + " if font_name in self.fonts:\n", + " return ImageFont.truetype(self.fonts[font_name], size)\n", + "\n", + " # Try fuzzy match (e.g., \"arial-bold\" matches \"arial\")\n", + " fuzzy_matches = [path for name, path in self.fonts.items()\n", + " if font_name in name and name != 'default']\n", + " if fuzzy_matches:\n", + " return ImageFont.truetype(fuzzy_matches[0], size)\n", + "\n", + " raise ValueError(f\"Font '{font_name}' not found\")\n", + "\n", + " except Exception as e:\n", + " print(f\"Error loading font {font_name}: {str(e)}\")\n", + " return ImageFont.load_default()\n", + "\n", + " def list_fonts(self):\n", + " \"\"\"List all available fonts\"\"\"\n", + " return ['default'] + sorted(list(self.fonts.keys()))\n", + "\n", + " def search_fonts(self, query):\n", + " \"\"\"Search for fonts containing the query string\"\"\"\n", + " query = query.lower()\n", + " matches = [name for name in self.fonts.keys() if query in name]\n", + " return sorted(matches)" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 3.2. List fonts" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "# Initialize font manager and list available fonts\n", + "font_manager = FontManager()\n", + "print(\"Available fonts:\")\n", + "print(font_manager.list_fonts())" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 4. GIF Processor Class" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from io import BytesIO\n", + "\n", + "\n", + "class GIFProcessor:\n", + " def __init__(self, gif_path):\n", + " \"\"\"Initialize with path to GIF file\"\"\"\n", + " self.gif = Image.open(gif_path)\n", + " self.frames = []\n", + " self.durations = []\n", + "\n", + " # Extract all frames and their durations\n", + " try:\n", + " while True:\n", + " self.frames.append(self.gif.copy())\n", + " self.durations.append(self.gif.info.get('duration', 100))\n", + " self.gif.seek(self.gif.tell() + 1)\n", + " except EOFError:\n", + " pass\n", + "\n", + " def resize(self, width, height):\n", + " \"\"\"Resize all frames to specified dimensions\"\"\"\n", + " self.frames = [frame.resize((width, height), Image.Resampling.LANCZOS)\n", + " for frame in self.frames]\n", + " return self\n", + "\n", + " def make_square(self, size=None):\n", + " \"\"\"\n", + " Crop the GIF to a square from the center.\n", + " If size is provided, the output will be resized to size x size.\n", + " If size is None, the square will be sized to the smaller dimension.\n", + " \"\"\"\n", + " if not self.frames:\n", + " return self\n", + "\n", + " # Get dimensions from first frame\n", + " width, height = self.frames[0].size\n", + "\n", + " # Calculate crop box for square\n", + " if width > height:\n", + " # Landscape orientation\n", + " left = (width - height) // 2\n", + " top = 0\n", + " right = left + height\n", + " bottom = height\n", + " else:\n", + " # Portrait orientation\n", + " left = 0\n", + " top = (height - width) // 2\n", + " right = width\n", + " bottom = top + width\n", + "\n", + " # Apply crop to all frames\n", + " self.frames = [frame.crop((left, top, right, bottom)) for frame in self.frames]\n", + "\n", + " # Resize if size is specified\n", + " if size is not None:\n", + " self.frames = [frame.resize((size, size), Image.Resampling.LANCZOS)\n", + " for frame in self.frames]\n", + "\n", + " return self\n", + "\n", + " def add_text(self, text, position, font_path=None, font_size=30,\n", + " color=(255, 255, 255), stroke_width=2, stroke_fill=(0, 0, 0)):\n", + " \"\"\"Add text overlay to all frames\"\"\"\n", + " font_manager = FontManager()\n", + " font = font_manager.get_font(font_name=font_path,size=font_size)\n", + "\n", + " for i, frame in enumerate(self.frames):\n", + " # Convert to RGBA before drawing\n", + " frame_rgba = frame.convert('RGBA')\n", + " draw = ImageDraw.Draw(frame_rgba)\n", + " draw.text(position, text, font=font, fill=color,\n", + " stroke_width=stroke_width, stroke_fill=stroke_fill)\n", + " self.frames[i] = frame_rgba\n", + " return self\n", + "\n", + " def add_image_overlay(self, overlay_path, position):\n", + " \"\"\"Add image overlay to all frames\"\"\"\n", + " overlay = Image.open(overlay_path).convert('RGBA')\n", + "\n", + " for i, frame in enumerate(self.frames):\n", + " frame_rgba = frame.convert('RGBA')\n", + " frame_rgba.paste(overlay, position, overlay)\n", + " self.frames[i] = frame_rgba\n", + " return self\n", + "\n", + " def optimize(self, quality=100):\n", + " if not self.frames:\n", + " return\n", + "\n", + " # Convert frames to RGB mode for saving\n", + " rgb_frames = []\n", + " for frame in self.frames:\n", + " rgb_frame = frame.convert('RGB')\n", + " # Calculate number of colors based on quality\n", + " n_colors = max(min(256, int(256 * (quality / 100))), 2)\n", + "\n", + " # Convert to P mode (palette) with optimized palette\n", + " rgb_frame = rgb_frame.quantize(\n", + " colors=n_colors,\n", + " method=Image.Quantize.MEDIANCUT,\n", + " dither=Image.Dither.FLOYDSTEINBERG\n", + " )\n", + " rgb_frames.append(rgb_frame)\n", + "\n", + " self.frames = rgb_frames\n", + "\n", + "\n", + " def save(self, output_path, optimize=False, quality=100):\n", + " \"\"\"\n", + " Save the processed GIF with optimization options\n", + "\n", + " Args:\n", + " output_path (str): Path to save the GIF\n", + " optimize (bool): Whether to optimize the GIF\n", + " quality (int): Quality from 1 (worst) to 100 (best).\n", + " Lower quality means smaller file size.\n", + " \"\"\"\n", + " if not self.frames:\n", + " return\n", + " if optimize:\n", + " self.optimize(quality)\n", + " rgb_frames = [frame.convert('RGB') for frame in self.frames]\n", + "\n", + " # Save with optimization\n", + " rgb_frames[0].save(\n", + " output_path,\n", + " save_all=True,\n", + " append_images=rgb_frames[1:],\n", + " optimize=optimize,\n", + " duration=self.durations,\n", + " loop=0,\n", + " format='GIF',\n", + " # Additional optimization parameters\n", + " disposal=2, # Clear the frame before rendering the next\n", + " quality=quality\n", + " )\n", + " print(\"Size on disk:\", f\"{os.path.getsize(output_path) / 1024 / 1024:.2f} MB\")\n", + "\n", + " def display(self):\n", + " \"\"\"Display the current state of the GIF in the notebook\"\"\"\n", + " temp_path = '_temp_display.gif'\n", + " self.save(temp_path)\n", + " display(IPImage(filename=temp_path))\n", + " os.remove(temp_path)\n", + "\n", + " def get_size(self, optimize=False, quality=100):\n", + " \"\"\"Get the size of the processed GIF in bytes without saving to disk\"\"\"\n", + " if self.frames:\n", + " # Convert frames back to RGB mode for saving\n", + " rgb_frames = [frame.convert('RGB') for frame in self.frames]\n", + " with BytesIO() as buffer:\n", + " rgb_frames[0].save(\n", + " buffer,\n", + " save_all=True,\n", + " append_images=rgb_frames[1:],\n", + " optimize=optimize,\n", + " quality=quality,\n", + " duration=self.durations,\n", + " loop=0,\n", + " format='GIF'\n", + " )\n", + " return buffer.tell()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 5. Create GIF" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "input_gif_path = os.path.join(INPUT_DIR, INPUT_GIF)\n", + "output_gif_path = os.path.join(OUTPUT_DIR, OUTPUT_GIF)\n", + "logo_path = os.path.join(ASSETS_DIR, LOGO_FILE)\n", + "\n", + "# Process the GIF\n", + "gif_processor = GIFProcessor(input_gif_path)\n", + "gif_processor.make_square(size=GIF_SIZE)\n", + "\n", + "# Add text overlays\n", + "for overlay in TEXT_OVERLAYS:\n", + " gif_processor.add_text(\n", + " text=overlay[\"text\"],\n", + " position=overlay[\"position\"],\n", + " font_path=overlay[\"font\"],\n", + " font_size=FONT_SIZE,\n", + " color=overlay[\"color\"],\n", + " stroke_width=overlay[\"stroke_width\"],\n", + " stroke_fill=overlay[\"stroke_fill\"]\n", + " )\n", + "\n", + "# Add logo overlay\n", + "if os.path.exists(logo_path):\n", + " gif_processor.add_image_overlay(\n", + " logo_path,\n", + " position=LOGO_POSITION\n", + " )\n", + "else:\n", + " print(f\"Warning: Logo file not found at {logo_path}\")\n", + "\n", + "# Optimize and display\n", + "gif_processor.optimize(quality=QUALITY)\n", + "print(\"\\nModified GIF:\")\n", + "gif_processor.display()\n", + "\n", + "# Save with compression\n", + "gif_processor.save(\n", + " output_gif_path,\n", + " optimize=False,\n", + " quality=QUALITY\n", + ")" + ], + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.6" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/other/media_generation/gif_processing_multiple.ipynb b/other/media_generation/gif_processing_multiple.ipynb new file mode 100644 index 00000000..6bb2b7bd --- /dev/null +++ b/other/media_generation/gif_processing_multiple.ipynb @@ -0,0 +1,668 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "# Batch GIF Processing Notebook\n", + "\n", + "This notebook provides functionality to:\n", + "1. Process multiple GIFs from an input folder\n", + "2. Resize all GIFs to specified square dimensions\n", + "3. Add text overlay with the GIF filename and standard text\n", + "4. Add logo overlay at selected positions\n", + "5. Compress the resulting GIFs\n", + "\n", + "# Usage\n", + "- Place your GIFs in the `input` directory, or keep them at the root of the `api-examples` folder if they were downloaded with `record_gifs_with_wave.ipynb`.\n", + "- Run All Cells to process the GIFs and save the results in the `output` directory.\n", + "- You can select available fonts for text overlays by running the `font_manager.list_fonts()` command in the 3.2. cell and setting `FONT_NAME` to one of the listed fonts.\n", + "- If you wish to use LLM to generate descriptions for the GIFs, install `ollama` and ensure the service is running and the model is pulled.\n", + "- Descriptions will be saved in `descriptions.txt` in the current working directory.\n", + "- You can customize the logo, text overlays, and GIF quality settings in the respective sections of the notebook.\n" + ], + "id": "28f8a295c4c03fa5" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 1. Import of Libraries", + "id": "96b41f843ed53919" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "import re\n", + "from PIL import Image, ImageDraw, ImageFont\n", + "import os\n", + "from IPython.display import display, Image as IPImage\n", + "import numpy as np\n" + ], + "id": "91d7d9b05d472f26", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "import os\n", + "import shutil\n", + "import re\n", + "\n", + "\n", + "def copy_and_clean_gifs(source_folder, target_folder=\"input\"):\n", + " \"\"\"\n", + " Copy GIF files from source folder to target folder,\n", + " removing numbered duplicates (e.g., removes 'xxx1.gif' if 'xxx.gif' exists)\n", + "\n", + " Args:\n", + " source_folder (str): Path to source folder containing GIFs\n", + " target_folder (str): Path to target folder (defaults to 'input')\n", + " \"\"\"\n", + "\n", + " # Ensure target folder exists\n", + " os.makedirs(target_folder, exist_ok=True)\n", + "\n", + " # Get all GIF files from source\n", + " gif_files = [f for f in os.listdir(source_folder) if f.lower().endswith('.gif')]\n", + "\n", + " # Dictionary to store base names and their variations\n", + " file_groups = {}\n", + "\n", + " # Group files by their base names\n", + " for file in gif_files:\n", + " # Remove .gif extension\n", + " base = file[:-4]\n", + " # Check if the filename ends with a number\n", + " match = re.match(r'(.*?)\\d+$', base)\n", + "\n", + " if match:\n", + " # If it has a number, use the part before the number as key\n", + " key = match.group(1).rstrip()\n", + " else:\n", + " # If no number, use the whole base as key\n", + " key = base\n", + "\n", + " if key not in file_groups:\n", + " file_groups[key] = []\n", + " file_groups[key].append(file)\n", + "\n", + " # Copy files, skipping numbered versions if base version exists\n", + " copied_count = 0\n", + " skipped_count = 0\n", + "\n", + " for base_name, variations in file_groups.items():\n", + " # Sort variations to ensure base version (without number) comes first if it exists\n", + " variations.sort(key=lambda x: (len(x), x))\n", + "\n", + " # Copy the first variation (usually the base version)\n", + " source_path = os.path.join(source_folder, variations[0])\n", + " target_path = os.path.join(target_folder, variations[0])\n", + " shutil.copy2(source_path, target_path)\n", + " copied_count += 1\n", + "\n", + " # Count skipped variations\n", + " skipped_count += len(variations) - 1\n", + "\n", + " print(f\"Copied {copied_count} files\")\n", + " if skipped_count > 0:\n", + " print(f\"Skipped {skipped_count} numbered variations\")\n", + "\n", + "# Example usage:\n", + "# copy_and_clean_gifs(\"/path/to/source/folder\")\n", + "# Or with custom target: copy_and_clean_gifs(\"/path/to/source\", \"custom_input\")" + ], + "id": "beafdf7911dee370", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 2. Input/Output Settings", + "id": "87d687ab58c121c0" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "# Directory settings\n", + "INPUT_DIR = \"input\"\n", + "OUTPUT_DIR = \"output\"\n", + "ASSETS_DIR = \"assets\"\n", + "\n", + "# GIF Settings\n", + "GIF_SIZE = 600 # Size for square output\n", + "QUALITY = 30 # GIF quality (1-100)\n", + "\n", + "# Logo Settings\n", + "LOGO_FILE = \"logo.png\" # Place your logo in files/assets/\n", + "LOGO_POSITION = (15, 10)\n", + "\n", + "# Text Overlay Settings\n", + "FONT_SIZE = 16\n", + "TEXT_COLOR = (255, 255, 255)\n", + "STROKE_COLOR = (0, 0, 0)\n", + "FONT_NAME = \"lucida-grande\" # Use `font_manager.list_fonts()` below in 3.2. to see available fonts\n", + "\n", + "TEXT_2 = \"Available in our materials bank\"" + ], + "id": "8c4fedfe514706bd", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 2.2. LLM Description Generation", + "id": "95a6cefb58ecd9ba" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "# Before running this cell, make sure to run `ollama pull && ollama run ` in the terminal\n", + "import importlib.util\n", + "import subprocess\n", + "import os\n", + "\n", + "# Check if ollama is installed\n", + "ollama_installed = importlib.util.find_spec(\"ollama\") is not None\n", + "\n", + "\n", + "# Check if ollama service is running\n", + "def is_ollama_running():\n", + " try:\n", + " result = subprocess.run([\"pgrep\", \"-f\", \"ollama\"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n", + " return result.returncode == 0\n", + " except Exception:\n", + " return False\n", + "\n", + "\n", + "if ollama_installed and is_ollama_running():\n", + " import ollama\n", + "\n", + " model_name = 'llama2'\n", + "\n", + " model_config = {\n", + " 'temperature': 0.1, # Controls randomness (0.0 - 1.0)\n", + " 'top_p': 0.9, # Nucleus sampling parameter\n", + " 'top_k': 40, # Top-k sampling parameter\n", + " 'num_predict': 100, # Max tokens to generate\n", + " 'repeat_penalty': 1.1 # Penalize repetition\n", + " }\n", + "\n", + "\n", + " def get_raw_completion(prompt, model=model_name, model_config=model_config):\n", + " response = ollama.generate(\n", + " model=model,\n", + " prompt=prompt,\n", + " options=model_config\n", + " )\n", + " return response['response']\n", + "\n", + "\n", + " def add_description_for_file(filename):\n", + " system_prompt = \"\"\"You are a materials science robot. Analyze the given filename\n", + " containing information about materials. Extract information about:\n", + " 1. The full colloquial name of materials involved\n", + " 2. Their crystallographic orientations\n", + " 3. The interface relationship and additional parameters if present\n", + " Output only a short concise accurate description of the material. Within template: \"Interface between Full_Material_Name_X(orientation) and Full_Material_Name_Y(orientation) (), strain-matching supercell.\" Only the description, no explanation beyond that.\"\"\"\n", + "\n", + " user_prompt = f\"\"\"Please analyze this materials science filename: {filename}\n", + " Write a short description of material within template.\"\"\"\n", + "\n", + " response = get_raw_completion(system_prompt + user_prompt)\n", + "\n", + " return response\n", + "else:\n", + " print(\"Ollama is not installed or not running. Skipping description generation.\\n\" +\n", + " \"To use this feature, please install Ollama from `https://ollama.com/download`\\n\" +\n", + " \"Make sure to run `ollama pull && ollama run ` in the terminal\")\n", + "\n" + ], + "id": "e6f02079d12a29f4", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 2.1. Copy and Clean GIFs", + "id": "839f510115d46896" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "import json\n", + "\n", + "# GIFs generated by `record_gifs_with_wave.ipynb` are downloaded to the root of `api-examples`\n", + "# They need to be copied to input directory and pruned from duplications due to possible bugs.\n", + "current_dir = os.getcwd()\n", + "print(current_dir)\n", + "parent_dir = os.path.abspath(os.path.join(os.getcwd(), os.pardir, os.pardir))\n", + "print(parent_dir)\n", + "copy_and_clean_gifs(parent_dir, INPUT_DIR)\n", + "\n", + "# Some symbols needed to be encoded and decoded\n", + "# Load the symbols from the JSON file\n", + "with open('symbols_map.json', 'r') as file:\n", + " symbols = json.load(file)\n", + "\n", + "# Extract the \"OVER\" symbol\n", + "SLASH_SYMBOL = symbols[\"/\"]\n", + "\n", + "# Create directories if they don't exist\n", + "for directory in [INPUT_DIR, OUTPUT_DIR, ASSETS_DIR]:\n", + " os.makedirs(directory, exist_ok=True)" + ], + "id": "4ac1cc7071a612a2", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 2.2. Define Text Overlays", + "id": "6758710ce94551ef" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "def create_text_overlays(filename):\n", + " \"\"\"Create text overlays using the GIF filename as text_1\"\"\"\n", + " # Clean up filename by removing extension and replacing underscores/hyphens with spaces\n", + " clean_name = os.path.splitext(filename)[0].replace(SLASH_SYMBOL, \"/\")\n", + "\n", + " return [\n", + " {\n", + " \"text\": clean_name,\n", + " \"position\": (10, GIF_SIZE - 10 - FONT_SIZE), # Bottom left\n", + " \"font\": FONT_NAME,\n", + " \"color\": TEXT_COLOR,\n", + " \"stroke_width\": 2,\n", + " \"stroke_fill\": STROKE_COLOR\n", + " },\n", + " {\n", + " \"text\": TEXT_2,\n", + " \"position\": (GIF_SIZE // 2 + 50, GIF_SIZE - 10 - FONT_SIZE), # Bottom right\n", + " \"font\": FONT_NAME,\n", + " \"color\": TEXT_COLOR,\n", + " \"stroke_width\": 2,\n", + " \"stroke_fill\": STROKE_COLOR\n", + " }\n", + " ]\n" + ], + "id": "4340871c4e59cc92", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 3. Font Manager Class", + "id": "4b28ffce16cc2b8f" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "import matplotlib.font_manager as fm\n", + "\n", + "\n", + "class FontManager:\n", + " \"\"\"Manages fonts for the GIF processor\"\"\"\n", + "\n", + " def __init__(self):\n", + " \"\"\"Initialize font manager and discover available fonts\"\"\"\n", + " self.fonts = self._discover_fonts()\n", + "\n", + " def _discover_fonts(self):\n", + " \"\"\"Discover all available fonts in the system\"\"\"\n", + " fonts = {}\n", + " for font in fm.fontManager.ttflist:\n", + " try:\n", + " name = font.name.lower().replace(' ', '-')\n", + " fonts[name] = font.fname\n", + " except Exception:\n", + " continue\n", + " return fonts\n", + "\n", + " def get_font(self, font_name='default', size=30):\n", + " \"\"\"Get a font by name and size\"\"\"\n", + " try:\n", + " if font_name == 'default':\n", + " return ImageFont.load_default()\n", + "\n", + " if font_name in self.fonts:\n", + " return ImageFont.truetype(self.fonts[font_name], size)\n", + "\n", + " fuzzy_matches = [path for name, path in self.fonts.items()\n", + " if font_name in name and name != 'default']\n", + " if fuzzy_matches:\n", + " return ImageFont.truetype(fuzzy_matches[0], size)\n", + "\n", + " raise ValueError(f\"Font '{font_name}' not found\")\n", + "\n", + " except Exception as e:\n", + " print(f\"Error loading font {font_name}: {str(e)}\")\n", + " return ImageFont.load_default()\n", + "\n", + " def list_fonts(self):\n", + " \"\"\"List all available fonts\"\"\"\n", + " return ['default'] + sorted(list(self.fonts.keys()))\n", + "\n", + " def search_fonts(self, query):\n", + " \"\"\"Search for fonts containing the query string\"\"\"\n", + " query = query.lower()\n", + " matches = [name for name in self.fonts.keys() if query in name]\n", + " return sorted(matches)\n" + ], + "id": "eb7d268efcb8e4be", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "### 3.2. List fonts", + "id": "319e3f442312162a" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "# Initialize font manager and list available fonts\n", + "font_manager = FontManager()\n", + "print(\"Available fonts:\")\n", + "# print(font_manager.list_fonts())\n" + ], + "id": "466a49d667f00d13", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 4. GIF Processor Class", + "id": "fde58fe5fb75c662" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "from io import BytesIO\n", + "\n", + "\n", + "class GIFProcessor:\n", + " def __init__(self, gif_path):\n", + " \"\"\"Initialize with path to GIF file\"\"\"\n", + " self.gif = Image.open(gif_path)\n", + " self.frames = []\n", + " self.durations = []\n", + "\n", + " # Extract all frames and their durations\n", + " try:\n", + " while True:\n", + " self.frames.append(self.gif.copy())\n", + " self.durations.append(self.gif.info.get('duration', 100))\n", + " self.gif.seek(self.gif.tell() + 1)\n", + " except EOFError:\n", + " pass\n", + "\n", + " def resize(self, width, height):\n", + " \"\"\"Resize all frames to specified dimensions\"\"\"\n", + " self.frames = [frame.resize((width, height), Image.Resampling.LANCZOS)\n", + " for frame in self.frames]\n", + " return self\n", + "\n", + " def make_square(self, size=None):\n", + " \"\"\"\n", + " Crop the GIF to a square from the center.\n", + " If size is provided, the output will be resized to size x size.\n", + " If size is None, the square will be sized to the smaller dimension.\n", + " \"\"\"\n", + " if not self.frames:\n", + " return self\n", + "\n", + " # Get dimensions from first frame\n", + " width, height = self.frames[0].size\n", + "\n", + " # Calculate crop box for square\n", + " if width > height:\n", + " # Landscape orientation\n", + " left = (width - height) // 2\n", + " top = 0\n", + " right = left + height\n", + " bottom = height\n", + " else:\n", + " # Portrait orientation\n", + " left = 0\n", + " top = (height - width) // 2\n", + " right = width\n", + " bottom = top + width\n", + "\n", + " # Apply crop to all frames\n", + " self.frames = [frame.crop((left, top, right, bottom)) for frame in self.frames]\n", + "\n", + " # Resize if size is specified\n", + " if size is not None:\n", + " self.frames = [frame.resize((size, size), Image.Resampling.LANCZOS)\n", + " for frame in self.frames]\n", + "\n", + " return self\n", + "\n", + " def add_text(self, text, position, font_path=None, font_size=30,\n", + " color=(255, 255, 255), stroke_width=2, stroke_fill=(0, 0, 0)):\n", + " \"\"\"Add text overlay to all frames\"\"\"\n", + " font_manager = FontManager()\n", + " font = font_manager.get_font(font_name=font_path, size=font_size)\n", + "\n", + " for i, frame in enumerate(self.frames):\n", + " # Convert to RGBA before drawing\n", + " frame_rgba = frame.convert('RGBA')\n", + " draw = ImageDraw.Draw(frame_rgba)\n", + " draw.text(position, text, font=font, fill=color,\n", + " stroke_width=stroke_width, stroke_fill=stroke_fill)\n", + " self.frames[i] = frame_rgba\n", + " return self\n", + "\n", + " def add_image_overlay(self, overlay_path, position):\n", + " \"\"\"Add image overlay to all frames\"\"\"\n", + " overlay = Image.open(overlay_path).convert('RGBA')\n", + "\n", + " for i, frame in enumerate(self.frames):\n", + " frame_rgba = frame.convert('RGBA')\n", + " frame_rgba.paste(overlay, position, overlay)\n", + " self.frames[i] = frame_rgba\n", + " return self\n", + "\n", + " def optimize(self, quality=100):\n", + " if not self.frames:\n", + " return\n", + "\n", + " # Convert frames to RGB mode for saving\n", + " rgb_frames = []\n", + " for frame in self.frames:\n", + " rgb_frame = frame.convert('RGB')\n", + " # Calculate number of colors based on quality\n", + " n_colors = max(min(256, int(256 * (quality / 100))), 2)\n", + "\n", + " # Convert to P mode (palette) with optimized palette\n", + " rgb_frame = rgb_frame.quantize(\n", + " colors=n_colors,\n", + " method=Image.Quantize.MEDIANCUT,\n", + " dither=Image.Dither.FLOYDSTEINBERG\n", + " )\n", + " rgb_frames.append(rgb_frame)\n", + "\n", + " self.frames = rgb_frames\n", + "\n", + " def save(self, output_path, optimize=False, quality=100):\n", + " \"\"\"\n", + " Save the processed GIF with optimization options\n", + "\n", + " Args:\n", + " output_path (str): Path to save the GIF\n", + " optimize (bool): Whether to optimize the GIF\n", + " quality (int): Quality from 1 (worst) to 100 (best).\n", + " Lower quality means smaller file size.\n", + " \"\"\"\n", + " if not self.frames:\n", + " return\n", + " if optimize:\n", + " self.optimize(quality)\n", + " rgb_frames = [frame.convert('RGB') for frame in self.frames]\n", + "\n", + " # Save with optimization\n", + " rgb_frames[0].save(\n", + " output_path,\n", + " save_all=True,\n", + " append_images=rgb_frames[1:],\n", + " optimize=optimize,\n", + " duration=self.durations,\n", + " loop=0,\n", + " format='GIF',\n", + " # Additional optimization parameters\n", + " disposal=2, # Clear the frame before rendering the next\n", + " quality=quality\n", + " )\n", + " print(\"Size on disk:\", f\"{os.path.getsize(output_path) / 1024 / 1024:.2f} MB\")\n", + "\n", + " def display(self):\n", + " \"\"\"Display the current state of the GIF in the notebook\"\"\"\n", + " temp_path = '_temp_display.gif'\n", + " self.save(temp_path)\n", + " display(IPImage(filename=temp_path))\n", + " os.remove(temp_path)\n", + "\n", + " def get_size(self, optimize=False, quality=100):\n", + " \"\"\"Get the size of the processed GIF in bytes without saving to disk\"\"\"\n", + " if self.frames:\n", + " # Convert frames back to RGB mode for saving\n", + " rgb_frames = [frame.convert('RGB') for frame in self.frames]\n", + " with BytesIO() as buffer:\n", + " rgb_frames[0].save(\n", + " buffer,\n", + " save_all=True,\n", + " append_images=rgb_frames[1:],\n", + " optimize=optimize,\n", + " quality=quality,\n", + " duration=self.durations,\n", + " loop=0,\n", + " format='GIF'\n", + " )\n", + " return buffer.tell()\n" + ], + "id": "12609dcf66804351", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## 5. Process All GIFs", + "id": "348711dac2da3b96" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "\n", + "def process_all_gifs():\n", + " \"\"\"Process all GIFs in the input directory\"\"\"\n", + " # Get logo path\n", + " logo_path = os.path.join(ASSETS_DIR, LOGO_FILE)\n", + " if not os.path.exists(logo_path):\n", + " print(f\"Warning: Logo file not found at {logo_path}\")\n", + " return\n", + "\n", + " # Get all GIF files from input directory\n", + " gif_files = [f for f in os.listdir(INPUT_DIR) if f.lower().endswith('.gif')]\n", + "\n", + " if not gif_files:\n", + " print(\"No GIF files found in input directory\")\n", + " return\n", + "\n", + " print(f\"Found {len(gif_files)} GIF files to process\")\n", + "\n", + " # Process each GIF\n", + " for gif_file in gif_files:\n", + " try:\n", + " print(f\"\\nProcessing: {gif_file}\")\n", + "\n", + " input_path = os.path.join(INPUT_DIR, gif_file)\n", + " output_path = os.path.join(OUTPUT_DIR, f\"{gif_file}\")\n", + "\n", + " # Create GIF processor\n", + " gif_processor = GIFProcessor(input_path)\n", + "\n", + " # Make square and resize\n", + " gif_processor.make_square(size=GIF_SIZE)\n", + "\n", + " # Add text overlays\n", + " text_overlays = create_text_overlays(gif_file)\n", + " for overlay in text_overlays:\n", + " gif_processor.add_text(\n", + " text=overlay[\"text\"],\n", + " position=overlay[\"position\"],\n", + " font_path=overlay[\"font\"],\n", + " font_size=FONT_SIZE,\n", + " color=overlay[\"color\"],\n", + " stroke_width=overlay[\"stroke_width\"],\n", + " stroke_fill=overlay[\"stroke_fill\"]\n", + " )\n", + "\n", + " # Add logo\n", + " gif_processor.add_image_overlay(logo_path, position=LOGO_POSITION)\n", + "\n", + " # Optimize and save\n", + " gif_processor.optimize(quality=QUALITY)\n", + " gif_processor.save(output_path, optimize=False, quality=QUALITY)\n", + "\n", + " # Add description\n", + " filename = text_overlays[0][\"text\"]\n", + " description = add_description_for_file(filename)\n", + " print(f\"Filename: {filename}\")\n", + " print(f\"Generated Description: {description}\")\n", + " with open('descriptions.txt', 'a') as file:\n", + " file.write(f\"Filename: {filename}\\n\" + f\"Generated Description: {description}\\n\")\n", + "\n", + " print(f\"Successfully processed: {gif_file}\")\n", + "\n", + " except Exception as e:\n", + " print(f\"Error processing {gif_file}: {str(e)}\")\n", + " continue\n", + "\n", + "\n", + "# Run the batch processing\n", + "process_all_gifs()" + ], + "id": "228b136f3a8379d4", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "", + "id": "543b54f4f66b74b1", + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "name": "python3", + "language": "python", + "display_name": "Python 3 (ipykernel)" + } + }, + "nbformat": 5, + "nbformat_minor": 9 +} diff --git a/other/media_generation/record_gifs_with_wave.ipynb b/other/media_generation/record_gifs_with_wave.ipynb new file mode 100644 index 00000000..9b4e8d65 --- /dev/null +++ b/other/media_generation/record_gifs_with_wave.ipynb @@ -0,0 +1,252 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5542df901e7c76cc", + "metadata": {}, + "source": [ + "# GIF Generation with Wave.js\n", + "\n", + "This notebook allows to record rotating GIFs of materials using the Wave.js viewer.\n", + "\n", + "## Usage\n", + "1. Set the URL of the Wave.js viewer (either local or deployed).\n", + "2. Set the folder containing the JSON files of the materials.\n", + "3. Run the notebook to generate GIFs for each material in the specified folder.\n", + "\n", + "\n", + "## 1. Settings of Notebook" + ] + }, + { + "cell_type": "code", + "id": "79ee60afd06b7b3f", + "metadata": {}, + "source": [ + "# URL = \"https://deploy-preview-165--wave-js.netlify.app/\" # or\n", + "URL = \"http://localhost:3002/\"\n", + "JSON_FOLDER = \"uploads\"" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "a10c370c09229a6b", + "metadata": {}, + "source": [ + "## 2. Loading of materials" + ] + }, + { + "cell_type": "code", + "id": "191df844bfc61a4e", + "metadata": {}, + "source": [ + "import os, json\n", + "def load_json_files_from_folder(folder_path):\n", + " json_files = [f for f in os.listdir(folder_path) if f.endswith('.json')]\n", + " json_data = []\n", + " file_names = []\n", + "\n", + " for json_file in json_files:\n", + " file_path = os.path.join(folder_path, json_file)\n", + " with open(file_path, 'r') as f:\n", + " data = json.load(f)\n", + " json_data.append(data)\n", + " file_names.append(os.path.splitext(json_file)[0])\n", + "\n", + " return json_data, file_names\n", + "\n", + "json_data, file_names = load_json_files_from_folder(JSON_FOLDER)\n", + "\n", + "materials_settings = []\n", + "for data in json_data:\n", + " materials_settings.append({\n", + " \"material_json\": data,\n", + " \"name\": file_names[json_data.index(data)] # name of the file\n", + " # \"name\": data[\"name\"] # name of the material\n", + " })" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "384f7d7270ecbf53", + "metadata": {}, + "source": "## 3. Actions Definitions" + }, + { + "cell_type": "code", + "id": "93be5f1320e953c6", + "metadata": {}, + "source": [ + "from IPython.display import display, IFrame, Javascript\n", + "\n", + "import time\n", + "\n", + "def send_message_to_iframe(message):\n", + " js_code = f\"\"\"\n", + " (function() {{\n", + " const iframe = document.querySelector('iframe');\n", + " if (iframe && iframe.contentWindow) {{\n", + " try {{\n", + " iframe.contentWindow.postMessage({json.dumps(message)}, '*');\n", + " console.log('Message sent to iframe');\n", + " }} catch (error) {{\n", + " alert('Error sending message to iframe. See console for more information.', error);\n", + " }}\n", + " }} else {{\n", + " console.error('Iframe not found or not ready');\n", + " }}\n", + " }})();\n", + " \"\"\"\n", + " display(Javascript(js_code))\n", + "\n", + "\n", + "def set_material_in_iframe(material_json):\n", + " \"\"\"\n", + " Uses send_message_to_iframe to send a material configuration\n", + " under the \"material\" key to the iframe at `url`.\n", + " \"\"\"\n", + " message = {\n", + " \"action\": \"handleSetMaterial\",\n", + " \"parameters\": [material_json]\n", + " }\n", + " send_message_to_iframe(message)\n", + "\n", + "\n", + "def record_gif(filename, rotation_speed=60, frame_duration=0.05):\n", + " \"\"\"\n", + " Uses send_message_to_iframe to send a message to the iframe at `url`\n", + " to start recording a GIF of the visualization with the specified parameters.\n", + " \"\"\"\n", + " message = {\n", + " \"action\": \"handleStartGifRecording\",\n", + " \"parameters\": [filename, rotation_speed, frame_duration]\n", + " }\n", + " send_message_to_iframe(message)\n", + "\n", + "def set_camera( pos=(15, 15, 15), target=(0, 0, 0)):\n", + " func_str = f\"camera.position.set({pos[0]},{pos[1]},{pos[2]})\"\n", + " message = {\n", + " \"action\": \"doWaveFunc\",\n", + " \"parameters\": [func_str]\n", + " }\n", + " send_message_to_iframe(message)\n", + "\n", + "def set_camera_to_fit_cell():\n", + " message = {\n", + " \"action\": \"handleSetCameraToFitCell\",\n", + " \"parameters\": [None]\n", + " }\n", + " send_message_to_iframe(message)\n", + "\n", + "def toggle_bonds():\n", + " message = {\n", + " \"action\": \"handleToggleBonds\",\n", + " \"parameters\": [None]\n", + " }\n", + " send_message_to_iframe(message)\n", + "\n", + "def handle_reset_viewer():\n", + " message = {\n", + " \"action\": \"handleResetViewer\",\n", + " \"parameters\": [None]\n", + " }\n", + " send_message_to_iframe(message)\n", + "\n", + "def test_do_func():\n", + " func_str = \"camera.position.set(0,16,0)\"\n", + " message = {\n", + " \"action\": \"doWaveFunc\",\n", + " \"parameters\": [func_str]\n", + " }\n", + " send_message_to_iframe(message)\n" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "592649ebe1e44c46", + "metadata": {}, + "source": [ + "## 4. Gif Generation\n", + "\n", + "### 4.1. Load the iframe with the Wave.js viewer" + ] + }, + { + "cell_type": "code", + "id": "d46c536490a3bdb3", + "metadata": {}, + "source": [ + "# Example usage with a material configuration:\n", + "iframe = IFrame(src=URL, width=\"600\", height=\"600\")\n", + "display(iframe)\n" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "### 4.2. Generate GIFs for each material", + "id": "fb71708af42b49e0" + }, + { + "cell_type": "code", + "id": "bd1e9c79e5457f3b", + "metadata": {}, + "source": [ + "for material_settings in materials_settings:\n", + " GIF_NAME = material_settings[\"name\"]+\".gif\" or material_settings[\"material_json\"][\"name\"] + \".gif\"\n", + " handle_reset_viewer()\n", + " time.sleep(1)\n", + " set_material_in_iframe(material_settings[\"material_json\"])\n", + " time.sleep(1)\n", + " toggle_bonds()\n", + " time.sleep(1)\n", + " set_camera_to_fit_cell()\n", + " time.sleep(1)\n", + " record_gif( GIF_NAME)\n", + " # We should wait until the gif is generated and saved before moving to the next material\n", + " parent_dir = os.path.abspath(os.path.join(os.getcwd(), os.pardir, os.pardir))\n", + " gif_path = os.path.join(parent_dir, GIF_NAME)\n", + " print(f\"Waiting for gif to be generated in {gif_path}\")\n", + "\n", + " # Wait until the gif file is created and downloaded\n", + " while not os.path.exists(gif_path):\n", + " time.sleep(1)\n", + " else:\n", + " print(f\"Generated gif for {GIF_NAME}\")\n", + " # time.sleep(30)\n" + ], + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv-3.11", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 5, + "nbformat_minor": 9 +} diff --git a/other/media_generation/symbols_map.json b/other/media_generation/symbols_map.json new file mode 100644 index 00000000..59cd9da3 --- /dev/null +++ b/other/media_generation/symbols_map.json @@ -0,0 +1,3 @@ +{ + "/": "OVER" +}