├── tests ├── __init__.py ├── Auto │ ├── Animator │ │ ├── tests0.png │ │ ├── tests1.png │ │ ├── Animator_test.py │ │ ├── AllocationCalculator │ │ │ ├── StaticAllocationCalculator │ │ │ │ ├── StaticAllocationStrategies │ │ │ │ │ ├── RandomAllocation_test.py │ │ │ │ │ ├── MagneticAllocation │ │ │ │ │ │ ├── MagnetOuterFrontier.py │ │ │ │ │ │ └── MagneticAllocation_test.py │ │ │ │ │ └── StaticAllocationStrategy_test.py │ │ │ │ └── StaticAllocationCalculator_test.py │ │ │ └── AnimatedAllocationCalculator_test.py │ │ ├── ImageCreator_test.py │ │ └── AnimationIntegrator_test.py │ ├── test_test.py │ └── Utils │ │ ├── Rect_test.py │ │ ├── Consts_test.py │ │ ├── Word_test.py │ │ ├── AllocationData_test.py │ │ ├── Collision_test.py │ │ ├── Vector_test.py │ │ └── TimelapseWordVector_test.py ├── Manual │ ├── AnimationVisualization.py │ └── StaticAllocationVisualization.py └── TestDataGetter.py ├── AnimatedWordCloud ├── __main__.py ├── output │ └── .gitkeep ├── Animator │ ├── __init__.py │ ├── AllocationCalculator │ │ ├── __init__.py │ │ ├── StaticAllocationCalculator │ │ │ ├── __init__.py │ │ │ ├── StaticAllocationStrategies │ │ │ │ ├── MagneticAllocation │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── MagnetOuterFrontier.py │ │ │ │ │ └── MagneticAllocation.py │ │ │ │ ├── __init__.py │ │ │ │ ├── RandomAllocation.py │ │ │ │ └── StaticAllocationStrategy.py │ │ │ └── StaticAllocationCalculator.py │ │ ├── AllocationCalculator.py │ │ └── AnimatetdAllocationCalculator.py │ ├── AnimationIntegrator.py │ ├── Animator.py │ └── ImageCreator.py ├── __init__.py ├── Assets │ └── Fonts │ │ └── NotoSansMono-VariableFont_wdth,wght.ttf └── Utils │ ├── Data │ ├── __init__.py │ ├── Word.py │ ├── Rect.py │ ├── AllocationData.py │ └── TimelapseWordVector.py │ ├── FileManager.py │ ├── Consts.py │ ├── __init__.py │ ├── Collisions.py │ ├── Vector.py │ └── Config.py ├── MANIFEST.in ├── .gitignore ├── .gitmodules ├── .github ├── ISSUE_TEMPLATE │ ├── development-declaration.md │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ ├── lint.yml │ ├── python-publish.yml │ ├── python-tester.yml │ ├── python-coverage-PR.yml │ └── codeql.yml ├── SECURITY.md ├── Docs ├── Dependencies │ ├── Root.puml │ ├── Utils.puml │ ├── StaticAllocationStrategies.puml │ └── Data.puml └── ModulesFlow.puml ├── .codeclimate.yml ├── LICENSE ├── setup.py ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /AnimatedWordCloud/__main__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /AnimatedWordCloud/output/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include AnimatedWordCloud/Assets * -------------------------------------------------------------------------------- /tests/Auto/Animator/tests0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/konbraphat51/AnimatedWordCloud/HEAD/tests/Auto/Animator/tests0.png -------------------------------------------------------------------------------- /tests/Auto/Animator/tests1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/konbraphat51/AnimatedWordCloud/HEAD/tests/Auto/Animator/tests1.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/** 2 | dist/** 3 | AnimatedWordCloud.egg-info/** 4 | AnimatedWordCloudTimelapse.egg-info/** 5 | **/__pycache__/** -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/test_data"] 2 | path = tests/test_data 3 | url = https://github.com/konbraphat51/Timelapse_Text 4 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/__init__.py: -------------------------------------------------------------------------------- 1 | from AnimatedWordCloud.Animator.Animator import animate 2 | 3 | __all__ = ["animate"] 4 | -------------------------------------------------------------------------------- /AnimatedWordCloud/__init__.py: -------------------------------------------------------------------------------- 1 | from AnimatedWordCloud.Animator import animate 2 | from AnimatedWordCloud.Utils import Config 3 | 4 | __all__ = ["animate", "Config"] 5 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AllocationCalculator/__init__.py: -------------------------------------------------------------------------------- 1 | from AnimatedWordCloud.Animator.AllocationCalculator.AllocationCalculator import ( 2 | allocate, 3 | ) 4 | 5 | __all__ = ["allocate"] 6 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Assets/Fonts/NotoSansMono-VariableFont_wdth,wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/konbraphat51/AnimatedWordCloud/HEAD/AnimatedWordCloud/Assets/Fonts/NotoSansMono-VariableFont_wdth,wght.ttf -------------------------------------------------------------------------------- /tests/Auto/test_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Testing the tester 8 | """ 9 | 10 | 11 | def test_test(): 12 | assert 2 + 2 == 4 13 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AllocationCalculator/StaticAllocationCalculator/__init__.py: -------------------------------------------------------------------------------- 1 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationCalculator import ( 2 | allocate, 3 | allocate_all, 4 | ) 5 | 6 | __all__ = [ 7 | "allocate", 8 | "allocate_all", 9 | ] 10 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationStrategies/MagneticAllocation/__init__.py: -------------------------------------------------------------------------------- 1 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies.MagneticAllocation.MagneticAllocation import ( 2 | MagneticAllocation, 3 | ) 4 | 5 | __all__ = [ 6 | "MagneticAllocation", 7 | ] 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/development-declaration.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Development Declaration 3 | about: Declear what you starting to develop here 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## What needs? 11 | refer an issue or point out some needs 12 | 13 | ## What are you doing? 14 | planned implementation 15 | 16 | ## Repository 17 | Put fork / branch URL here 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What needs occurred 2 | also refer to an issue 3 | 4 | ## What you changed (What reviewers have to see) 5 | list them ALL 6 | 7 | also refer an issue of development declaration 8 | 9 | ## Description 10 | Write something reviewers may be difficult to understand without explanation 11 | 12 | ## Check list 13 | - [ ] Code styles confirmed 14 | - [ ] Test confirmed 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Possible Implementation** 14 | Add any other context or screenshots about the feature request here. 15 | -------------------------------------------------------------------------------- /tests/Manual/AnimationVisualization.py: -------------------------------------------------------------------------------- 1 | """ 2 | Observes how animation words 3 | """ 4 | 5 | from AnimatedWordCloud import Config, animate 6 | from tests.TestDataGetter import raw_timelapses_test 7 | 8 | # testing data 9 | raw_timelapse = raw_timelapses_test[0] 10 | 11 | config = Config( 12 | max_words=50, max_font_size=20, min_font_size=10, verbosity="debug" 13 | ) 14 | 15 | animate(raw_timelapse, config) 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.0 | ✅ | 8 | 9 | ## If any problem found 10 | Use [GitHub private report system](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) 11 | -------------------------------------------------------------------------------- /Docs/Dependencies/Root.puml: -------------------------------------------------------------------------------- 1 | @startuml Dependency_Root 2 | ' Image can be obtained from 3 | ' https://www.plantuml.com/plantuml/uml/SyfFKj2rKt3CoKnELR1Io4ZDoSa70000 4 | 5 | ' Write all modules here 6 | object Animator { 7 | * animate() 8 | } 9 | 10 | object Utils #LightBlue { 11 | * Vector 12 | * Consts 13 | } 14 | 15 | ' Write all dependencies here 16 | ' X --> Y means X depends on Y 17 | Animator --> Utils 18 | 19 | @enduml -------------------------------------------------------------------------------- /tests/Auto/Utils/Rect_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Testing the Rect module 8 | """ 9 | 10 | from AnimatedWordCloud.Utils import Rect 11 | 12 | 13 | def test_Rect(): 14 | instance = Rect((0, 0), (100, 100)) 15 | 16 | assert instance.left_top == (0, 0) 17 | assert instance.right_bottom == (100, 100) 18 | -------------------------------------------------------------------------------- /Docs/Dependencies/Utils.puml: -------------------------------------------------------------------------------- 1 | @startuml Dependency_Utils 2 | ' Image can be obtained from 3 | ' https://www.plantuml.com/plantuml/uml/SyfFKj2rKt3CoKnELR1Io4ZDoSa70000 4 | 5 | ' Write all modules here 6 | object Data { 7 | * all data classes 8 | } 9 | 10 | object Vector { 11 | * Vector 12 | * Consts 13 | } 14 | 15 | object Collisition { 16 | * Collisition 17 | } 18 | 19 | ' Write all dependencies here 20 | ' X --> Y means X depends on Y 21 | Vector <-- Data 22 | Collisition --> Data 23 | 24 | @enduml -------------------------------------------------------------------------------- /tests/Auto/Animator/Animator_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Observes how animation words 3 | """ 4 | 5 | from AnimatedWordCloud import Config, animate 6 | from tests.TestDataGetter import raw_timelapses_test 7 | 8 | # testing data 9 | raw_timelapse = raw_timelapses_test[0] 10 | 11 | less_raw_timelapse = raw_timelapse[:2] 12 | 13 | config = Config( 14 | max_words=50, max_font_size=20, min_font_size=10, verbosity="debug" 15 | ) 16 | 17 | 18 | def test_animate(): 19 | assert animate(less_raw_timelapse, config) != None 20 | -------------------------------------------------------------------------------- /tests/Auto/Utils/Consts_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Testing Consts module 8 | """ 9 | 10 | from pathlib import Path 11 | from AnimatedWordCloud.Utils.Consts import ( 12 | LIBRARY_DIR, 13 | DEFAULT_ENG_FONT_PATH, 14 | ) 15 | 16 | 17 | def test_globals(): 18 | assert LIBRARY_DIR.exists() 19 | assert Path(DEFAULT_ENG_FONT_PATH).exists() 20 | -------------------------------------------------------------------------------- /tests/Auto/Utils/Word_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Testing the Word module 8 | """ 9 | 10 | from AnimatedWordCloud.Utils import ( 11 | Word, 12 | ) 13 | 14 | 15 | def test_word(): 16 | instance = Word("test", 10, 20, (0, 0)) 17 | assert instance.text == "test" 18 | assert instance.weight == 10 19 | assert instance.font_size == 20 20 | assert instance.text_size == (0, 0) 21 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Utils/Data/__init__.py: -------------------------------------------------------------------------------- 1 | from AnimatedWordCloud.Utils.Data.AllocationData import ( 2 | AllocationInFrame, 3 | AllocationTimelapse, 4 | ) 5 | from AnimatedWordCloud.Utils.Data.TimelapseWordVector import ( 6 | WordVector, 7 | TimeFrame, 8 | TimelapseWordVector, 9 | ) 10 | from AnimatedWordCloud.Utils.Data.Rect import Rect 11 | from AnimatedWordCloud.Utils.Data.Word import Word 12 | 13 | __all__ = [ 14 | "AllocationInFrame", 15 | "AllocationTimelapse", 16 | "WordVector", 17 | "TimeFrame", 18 | "TimelapseWordVector", 19 | "Rect", 20 | "Word", 21 | ] 22 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Utils/FileManager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Modules for controling file managements 8 | """ 9 | 10 | import os 11 | 12 | 13 | def ensure_directory_exists(directory_path: str) -> None: 14 | """ 15 | Verify that the directory exists. If it does not exist, create another directory. 16 | 17 | :param 18 | - str directory_path 19 | :rtype: None 20 | """ 21 | if not os.path.exists(directory_path): 22 | os.makedirs(directory_path) 23 | 24 | return 25 | -------------------------------------------------------------------------------- /Docs/Dependencies/StaticAllocationStrategies.puml: -------------------------------------------------------------------------------- 1 | @startuml Dependency_StaticAllocationStrategies 2 | ' Image can be obtained from 3 | ' https://www.plantuml.com/plantuml/uml/SyfFKj2rKt3CoKnELR1Io4ZDoSa70000 4 | 5 | ' Write all modules here 6 | object StaticAllocationStrategy { 7 | base class 8 | } 9 | 10 | object RandomAllocation { 11 | allocate_randomly() 12 | } 13 | 14 | object MagneticAllocation { 15 | class 16 | } 17 | 18 | 19 | ' Write all dependencies here 20 | ' X --> Y means X depends on Y 21 | StaticAllocationStrategy --> RandomAllocation : use allocate_randomly() 22 | StaticAllocationStrategy --|> MagneticAllocation : extends 23 | 24 | @enduml -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationStrategies/__init__.py: -------------------------------------------------------------------------------- 1 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies.StaticAllocationStrategy import ( 2 | StaticAllocationStrategy, 3 | ) 4 | 5 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies.MagneticAllocation import ( 6 | MagneticAllocation, 7 | ) 8 | 9 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies.RandomAllocation import ( 10 | allocate_randomly, 11 | ) 12 | 13 | __all__ = [ 14 | "StaticAllocationStrategy", 15 | "allocate_randomly", 16 | "MagneticAllocation", 17 | ] 18 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Utils/Data/Word.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Data class contains attributes of each words 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | 13 | class Word: 14 | """ 15 | Data class contains attributes of each words. 16 | This is used to contact with allocation strategies. 17 | """ 18 | 19 | def __init__( 20 | self, 21 | text: str, 22 | weight: float, 23 | font_size: int, 24 | text_size: tuple[int, int], 25 | ): 26 | self.text = text 27 | self.weight = weight 28 | self.font_size = font_size 29 | self.text_size = text_size 30 | -------------------------------------------------------------------------------- /tests/Manual/StaticAllocationVisualization.py: -------------------------------------------------------------------------------- 1 | """ 2 | Observes how Allocation words 3 | """ 4 | 5 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator import ( 6 | allocate_all, 7 | ) 8 | from AnimatedWordCloud.Animator.ImageCreator import create_images 9 | from AnimatedWordCloud.Utils import Config 10 | from tests.TestDataGetter import timelapses_test, TimelapseWordVector 11 | 12 | timelapse = timelapses_test[0] 13 | 14 | # prepare less data 15 | timelapse_less = TimelapseWordVector() 16 | timelapse_less.timeframes = timelapse.timeframes[:2] 17 | 18 | config = Config( 19 | max_words=50, max_font_size=20, min_font_size=10, verbosity="debug" 20 | ) 21 | 22 | allocation_timelapse = allocate_all(timelapse_less, config) 23 | 24 | create_images(allocation_timelapse, config) 25 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Utils/Data/Rect.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Handling rect in static allocation strategies 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | 13 | class Rect: 14 | """ 15 | Data of rectangle in static allocation strategies 16 | """ 17 | 18 | def __init__( 19 | self, left_top: tuple[int, int], right_bottom: tuple[int, int] 20 | ) -> None: 21 | """ 22 | :param Tuple[int,int] left_top: Left top position of the rect 23 | :param Tuple[int,int] right_bottom: Right bottom position of the rect 24 | """ 25 | self.left_top = left_top 26 | self.right_bottom = right_bottom 27 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Utils/Consts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Consts for AnimatedWordCloud functions 8 | """ 9 | 10 | 11 | from pathlib import Path 12 | import os 13 | 14 | 15 | LIBRARY_DIR = Path(__file__).parent.parent 16 | """ 17 | Directory of the library currently exists. 18 | Shows "AnimatedWordCloud" directory. 19 | """ 20 | 21 | DEFAULT_ENG_FONT_PATH = os.path.join( 22 | LIBRARY_DIR, 23 | "Assets", 24 | "Fonts", 25 | "NotoSansMono-VariableFont_wdth,wght.ttf", 26 | ) 27 | """ 28 | path of default Eng font file exists 29 | """ 30 | 31 | DEFAULT_OUTPUT_PATH = os.path.join(LIBRARY_DIR, "output") 32 | """ 33 | Default output path of the generated images when none is specified. 34 | """ 35 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | paths-ignore: 8 | - '**.md' 9 | - '**.yml' 10 | - 'docs/**' 11 | pull_request: 12 | branches: 13 | - dev 14 | paths-ignore: 15 | - '**.md' 16 | - '**.yml' 17 | - 'docs/**' 18 | 19 | jobs: 20 | build: 21 | 22 | runs-on: ubuntu-20.04 23 | strategy: 24 | matrix: 25 | python-version: [3.8] 26 | 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Set up Python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v4 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | - name: Check Code Format with Black 34 | uses: psf/black@stable 35 | with: 36 | options: "--check --verbose --line-length=79" 37 | src: "./AnimatedWordCloud" 38 | version: "~= 22.0" 39 | -------------------------------------------------------------------------------- /tests/Auto/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationStrategies/RandomAllocation_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | testing RandomAllocation class 8 | """ 9 | 10 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies.RandomAllocation import ( 11 | allocate_randomly, 12 | put_randomly, 13 | Word, 14 | ) 15 | 16 | 17 | def test_RandomAllocation(): 18 | words = [ 19 | Word("test", 1, 10, (30, 10)), 20 | Word("test", 3, 30, (20, 30)), 21 | Word("test", 4, 40, (40, 40)), 22 | ] 23 | 24 | assert allocate_randomly(words, 100, 100) is not None 25 | 26 | 27 | def test_put_randomly(): 28 | assert put_randomly(100, 100, (10, 10)) is not None 29 | -------------------------------------------------------------------------------- /tests/Auto/Utils/AllocationData_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | testing the AnimatedWordCloud module 8 | """ 9 | 10 | from AnimatedWordCloud.Utils import ( 11 | AllocationInFrame, 12 | AllocationTimelapse, 13 | ) 14 | 15 | 16 | def test_AllocationInFrame(): 17 | instance = AllocationInFrame(from_static_allocation=True) 18 | 19 | # test add + getitem 20 | instance.add("test", 10, (0, 0)) 21 | instance.add("test2", 20, (10, 10)) 22 | assert instance["test"] == (10, (0, 0)) 23 | 24 | 25 | def test_AllocationTimelapse(): 26 | instance = AllocationTimelapse() 27 | 28 | # test add + getitem 29 | instance.add(0, AllocationInFrame(from_static_allocation=True)) 30 | instance.add(1, AllocationInFrame(from_static_allocation=True)) 31 | assert instance.get_frame(0) is not None 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" # required to adjust maintainability checks 2 | 3 | checks: 4 | argument-count: 5 | enabled: false 6 | config: 7 | threshold: 4 8 | complex-logic: 9 | enabled: true 10 | config: 11 | threshold: 4 12 | file-lines: 13 | enabled: true 14 | config: 15 | threshold: 300 16 | method-complexity: 17 | enabled: true 18 | config: 19 | threshold: 5 20 | method-count: 21 | enabled: true 22 | config: 23 | threshold: 20 24 | method-lines: 25 | enabled: true 26 | config: 27 | threshold: 25 28 | nested-control-flow: 29 | enabled: true 30 | config: 31 | threshold: 4 32 | return-statements: 33 | enabled: true 34 | config: 35 | threshold: 4 36 | similar-code: 37 | enabled: true 38 | config: 39 | threshold: #language-specific defaults. overrides affect all languages. 40 | identical-code: 41 | enabled: true 42 | config: 43 | threshold: #language-specific defaults. overrides affect all languages. 44 | -------------------------------------------------------------------------------- /Docs/ModulesFlow.puml: -------------------------------------------------------------------------------- 1 | @startuml flow 2 | 3 | title Animate() modules flow 4 | 5 | start 6 | note right: Animate() 7 | 8 | partition "Animator" { 9 | :data conversion: Convert dictionary data to TimelapseWordVector (inner) class; 10 | partition "AllocationCalculator" { 11 | partition "StaticAllocationCalculator" { 12 | :static allocation 13 | Allocate size & position of each words at the time of each of the timeframe given; 14 | } 15 | partition "AnimatedAllocationCalculator" { 16 | :dynamic allocation 17 | Allocate size & position of each words between the static allocations 18 | This makes the image animating; 19 | } 20 | } 21 | 22 | partition "ImageCreator" { 23 | :create image 24 | Create each frame's image from the allocated data; 25 | } 26 | 27 | partition "AnimationCreator" { 28 | :create animation 29 | Create animation from the images; 30 | } 31 | } 32 | 33 | end 34 | note right: gif path 35 | 36 | @enduml -------------------------------------------------------------------------------- /tests/Auto/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationStrategies/MagneticAllocation/MagnetOuterFrontier.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | testing MagnetOuterFrontier module 8 | """ 9 | 10 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies.MagneticAllocation.MagnetOuterFrontier import ( 11 | MagnetOuterFrontier, 12 | get_magnet_outer_frontier, 13 | Rect, 14 | is_point_hitting_rect, 15 | ) 16 | 17 | 18 | def test_MagneticOuterFrontier(): 19 | instance = MagnetOuterFrontier() 20 | assert instance.from_up == [] 21 | assert instance.from_down == [] 22 | assert instance.from_left == [] 23 | assert instance.from_right == [] 24 | 25 | 26 | def test_get_magnet_outer_frontier(): 27 | rect = Rect((30, 30), (50, 50)) 28 | frontier = get_magnet_outer_frontier([rect], 100, 100, 10) 29 | 30 | assert is_point_hitting_rect(frontier.from_up[1], rect) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 AnimatedWordCloud Project 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 | -------------------------------------------------------------------------------- /tests/Auto/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationStrategies/MagneticAllocation/MagneticAllocation_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | testing MagneticAllocation module 8 | """ 9 | 10 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies.MagneticAllocation.MagneticAllocation import ( 11 | MagneticAllocation, 12 | AllocationInFrame, 13 | Word, 14 | Config, 15 | ) 16 | 17 | 18 | def test_MagneticAllocation(): 19 | config = Config(image_width=1000, image_height=1000, image_division=200) 20 | instance = MagneticAllocation(config) 21 | 22 | allocation_previous = AllocationInFrame(from_static_allocation=True) 23 | allocation_previous.add("test", 10, (30, 10)) 24 | allocation_previous.add("test1", 30, (20, 30)) 25 | 26 | words = [ 27 | Word("test", 1, 10, (30, 10)), 28 | Word("test2", 3, 30, (20, 30)), 29 | ] 30 | 31 | assert instance.allocate(words, allocation_previous) is not None 32 | -------------------------------------------------------------------------------- /tests/Auto/Animator/ImageCreator_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Testing the ImageCreator classes 8 | """ 9 | import os 10 | import glob 11 | from AnimatedWordCloud.Animator.ImageCreator import create_images 12 | from AnimatedWordCloud.Utils import ( 13 | AllocationTimelapse, 14 | AllocationInFrame, 15 | ) 16 | from AnimatedWordCloud.Utils import ( 17 | Config, 18 | DEFAULT_OUTPUT_PATH, 19 | ) 20 | 21 | 22 | def test_imagecreator(): 23 | # test create_images function 24 | position_in_frames = AllocationTimelapse() 25 | allocation_in_frame = AllocationInFrame(from_static_allocation=True) 26 | allocation_in_frame.words = {"word": (30, (50, 50))} # dictionary 27 | position_in_frames.add("2023_04_01", allocation_in_frame) 28 | config = Config(intermediate_frames_id="test", verbosity="debug") 29 | assert len(create_images(position_in_frames, config)) > 0 30 | test_path = os.path.join(DEFAULT_OUTPUT_PATH, "test_0.png") 31 | assert os.path.isfile(test_path) 32 | os.remove(os.path.join(DEFAULT_OUTPUT_PATH, "test_0.png")) 33 | -------------------------------------------------------------------------------- /Docs/Dependencies/Data.puml: -------------------------------------------------------------------------------- 1 | @startuml Dependency_Data 2 | ' Image can be obtained from 3 | ' https://www.plantuml.com/plantuml/uml/SyfFKj2rKt3CoKnELR1Io4ZDoSa70000 4 | 5 | ' Write all modules here 6 | folder Animator { 7 | folder AllocationData { 8 | object AllocationInFrame { 9 | Positions and size of each words in a frame 10 | } 11 | 12 | object AllocationTimelapse { 13 | Timelapse of positions and size of each words 14 | } 15 | 16 | AllocationTimelapse *-- AllocationInFrame 17 | } 18 | 19 | folder TimelapseWordVector { 20 | object WordVector { 21 | Contains each word's weight 22 | } 23 | 24 | object TimeFrame { 25 | A single time frame of word vector 26 | } 27 | 28 | object TimelapseWordVector { 29 | Timelapse data of word vectors. 30 | } 31 | 32 | WordVector *-- TimeFrame 33 | TimelapseWordVector *-- TimeFrame 34 | } 35 | 36 | folder StaticAllocationStrategies { 37 | object Rect { 38 | A rectangle of a word 39 | } 40 | 41 | object Word { 42 | Data class contains attributes of each words. 43 | } 44 | } 45 | } 46 | 47 | @enduml -------------------------------------------------------------------------------- /AnimatedWordCloud/Utils/__init__.py: -------------------------------------------------------------------------------- 1 | from AnimatedWordCloud.Utils.Consts import ( 2 | LIBRARY_DIR, 3 | DEFAULT_ENG_FONT_PATH, 4 | DEFAULT_OUTPUT_PATH, 5 | ) 6 | 7 | from AnimatedWordCloud.Utils.Config import Config 8 | 9 | from AnimatedWordCloud.Utils.Vector import Vector 10 | 11 | from AnimatedWordCloud.Utils.Data import ( 12 | AllocationInFrame, 13 | AllocationTimelapse, 14 | WordVector, 15 | TimeFrame, 16 | TimelapseWordVector, 17 | Rect, 18 | Word, 19 | ) 20 | 21 | from AnimatedWordCloud.Utils.Collisions import ( 22 | is_point_hitting_rects, 23 | is_point_hitting_rect, 24 | is_rect_hitting_rect, 25 | is_rect_hitting_rects, 26 | ) 27 | 28 | from AnimatedWordCloud.Utils.FileManager import ( 29 | ensure_directory_exists, 30 | ) 31 | 32 | __all__ = [ 33 | "LIBRARY_DIR", 34 | "DEFAULT_ENG_FONT_PATH", 35 | "DEFAULT_OUTPUT_PATH", 36 | "Vector", 37 | "AllocationInFrame", 38 | "AllocationTimelapse", 39 | "WordVector", 40 | "TimeFrame", 41 | "TimelapseWordVector", 42 | "Rect", 43 | "Word", 44 | "is_point_hitting_rects", 45 | "is_point_hitting_rect", 46 | "is_rect_hitting_rect", 47 | "is_rect_hitting_rects", 48 | "Config", 49 | "ensure_directory_exists", 50 | ] 51 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up Python 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: "3.12" 28 | - name: Build package 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install setuptools wheel 32 | python setup.py sdist bdist_wheel 33 | - name: Publish package 34 | run: | 35 | pip install twine 36 | python -m twine upload dist/* 37 | env: 38 | TWINE_USERNAME: __token__ 39 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /tests/Auto/Utils/Collision_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Testing the Collision module 8 | """ 9 | 10 | from AnimatedWordCloud.Utils.Collisions import * 11 | 12 | 13 | def test_collisions(): 14 | rect0 = Rect((0, 0), (100, 100)) 15 | rect1 = Rect((75, 75), (150, 150)) 16 | rect2 = Rect((150, 150), (250, 250)) 17 | 18 | point0 = (50, 50) 19 | point1_1 = (149, 149) 20 | point1_2 = (150, 150) 21 | point2 = (300, 300) 22 | 23 | assert is_point_hitting_rect(point0, rect0) 24 | assert is_point_hitting_rect(point1_1, rect1) 25 | assert not is_point_hitting_rect(point1_2, rect1) 26 | assert not is_point_hitting_rect(point2, rect2) 27 | 28 | assert is_point_hitting_rects(point0, [rect0, rect1])[0] 29 | assert is_point_hitting_rects(point1_1, [rect0, rect1])[1].left_top == ( 30 | 75, 31 | 75, 32 | ) 33 | assert not is_point_hitting_rects(point2, [rect0, rect1])[0] 34 | 35 | assert is_rect_hitting_rect(rect0, rect1) 36 | assert not is_rect_hitting_rect(rect0, rect2) 37 | 38 | assert is_rect_hitting_rects(rect0, [rect1, rect2]) 39 | assert not is_rect_hitting_rects(rect0, [rect2]) 40 | -------------------------------------------------------------------------------- /.github/workflows/python-tester.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: unit-test 5 | 6 | on: 7 | push: 8 | branches: ["dev", "main"] 9 | pull_request: 10 | branches: ["dev", "main"] 11 | 12 | jobs: 13 | build: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 18 | platform: ["ubuntu-latest", "macos-latest", "windows-latest"] 19 | runs-on: ${{ matrix.platform }} 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | with: 24 | submodules: recursive 25 | 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v3 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | python -m pip install pytest 35 | python -m pip install -e . 36 | 37 | - name: Test with pytest 38 | env: 39 | PYTHONPATH: ${{ github.workspace }} 40 | run: | 41 | pytest 42 | -------------------------------------------------------------------------------- /.github/workflows/python-coverage-PR.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/marketplace/actions/pytest-coverage-comment#example-usage 2 | 3 | name: coveragePR 4 | 5 | on: [push, pull_request] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python-version: ["3.8"] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | submodules: recursive 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v3 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | python -m pip install pytest pytest-cov 29 | python -m pip install -e . 30 | 31 | - name: Test with pytest 32 | env: 33 | PYTHONPATH: ${{ github.workspace }} 34 | run: | 35 | pytest --cov AnimatedWordCloud 36 | 37 | - name: Pytest coverage comment 38 | id: coverageComment 39 | uses: MishaKav/pytest-coverage-comment@v1.1.47 40 | with: 41 | pytest-coverage-path: ./pytest-coverage.txt 42 | junitxml-path: ./pytest.xml 43 | 44 | - name: Upload coverage reports to Codecov 45 | uses: codecov/codecov-action@v3 46 | env: 47 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 48 | -------------------------------------------------------------------------------- /tests/Auto/Utils/Vector_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Testing the Vector classes 8 | """ 9 | 10 | from AnimatedWordCloud.Utils import Vector 11 | from pytest import raises 12 | 13 | 14 | def test_vector(): 15 | vec1 = Vector(1, 2) 16 | vec2 = Vector((3, 4)) 17 | 18 | # plus 19 | plus = vec1 + vec2 20 | assert plus.x == 4 21 | assert plus.y == 6 22 | 23 | # minus 24 | minus = vec1 - vec2 25 | assert minus.x == -2 26 | assert minus.y == -2 27 | 28 | # multiply 29 | multiply = vec1 * 2 30 | assert multiply.x == 2 31 | assert multiply.y == 4 32 | 33 | # divide 34 | divide = vec1 / 2 35 | assert divide.x == 0.5 36 | assert divide.y == 1 37 | 38 | # get item 39 | assert vec1[0] == 1 40 | assert vec1[1] == 2 41 | 42 | # clone 43 | clone = vec1.clone() 44 | clone += vec2 45 | assert clone.x == 4 46 | assert clone.y == 6 47 | assert vec1.x == 1 48 | assert vec1.y == 2 49 | 50 | # convert to tuple 51 | tup = vec1.convert_to_tuple() 52 | assert tup == (1, 2) 53 | 54 | # convert to str 55 | assert str(vec1).__class__ == str 56 | 57 | # invalid constructing 58 | with raises(ValueError): 59 | Vector(1, 2, 3) 60 | 61 | with raises(ValueError): 62 | Vector((1, 2, 3)) 63 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AllocationCalculator/AllocationCalculator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Calculate positions and size of words during each frame 8 | """ 9 | 10 | from AnimatedWordCloud.Utils import ( 11 | AllocationTimelapse, 12 | TimelapseWordVector, 13 | Config, 14 | ) 15 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator import ( 16 | allocate_all as allocate_static, 17 | ) 18 | from AnimatedWordCloud.Animator.AllocationCalculator.AnimatetdAllocationCalculator import ( 19 | animated_allocate, 20 | ) 21 | 22 | 23 | def allocate( 24 | word_vector_timelapse: TimelapseWordVector, config: Config 25 | ) -> AllocationTimelapse: 26 | """ 27 | Calculate positions and size of words during each frame 28 | 29 | This will call 30 | - StaticAllocationCalculator: Calculate positions and size of words in each frame 31 | - AnimatedAllocationCalculator: Calculate the motion of each word in between the static frames 32 | 33 | :param TimelapseWordVector word_vector_timelapse: 34 | :return: timelapse of calculated allocation 35 | :rtype: AllocationTimelapse 36 | """ 37 | 38 | # Calculate static allocation 39 | static_allocation_timelapse = allocate_static( 40 | word_vector_timelapse, config 41 | ) 42 | 43 | # Calculate animated allocation 44 | animated_allocation_timelapse = animated_allocate( 45 | static_allocation_timelapse, config 46 | ) 47 | 48 | return animated_allocation_timelapse 49 | -------------------------------------------------------------------------------- /tests/Auto/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationStrategies/StaticAllocationStrategy_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | testing StaticAllocationStrategy class 8 | """ 9 | 10 | from pytest import raises 11 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies.StaticAllocationStrategy import ( 12 | StaticAllocationStrategy, 13 | Word, 14 | Config, 15 | AllocationInFrame, 16 | ) 17 | 18 | 19 | def test_StaticAllocationStrategy(): 20 | config = Config(image_width=1000, image_height=1000) 21 | instance = StaticAllocationStrategy(config) 22 | 23 | with raises(NotImplementedError): 24 | instance.allocate([], AllocationInFrame(from_static_allocation=True)) 25 | 26 | allocation_previous = AllocationInFrame(from_static_allocation=True) 27 | allocation_previous.add("test", 10, (30, 10)) 28 | 29 | allocation_current = AllocationInFrame(from_static_allocation=True) 30 | allocation_current.add("test1", 20, (40, 10)) 31 | 32 | words_current = [Word("test2", 30, 30, (20, 30))] 33 | 34 | # test add_missing_word_to_previous_frame() 35 | instance.add_missing_word_to_previous_frame( 36 | allocation_previous, words_current 37 | ) 38 | assert allocation_previous["test2"] is not None 39 | 40 | # test add_missing_word_from_previous_frame() 41 | instance.add_missing_word_from_previous_frame( 42 | allocation_previous, allocation_current 43 | ) 44 | assert allocation_current["test"] is not None 45 | -------------------------------------------------------------------------------- /tests/TestDataGetter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Get data from test_data submodule 8 | """ 9 | 10 | from __future__ import annotations 11 | import json 12 | from pathlib import Path 13 | from AnimatedWordCloud.Utils import LIBRARY_DIR, TimelapseWordVector 14 | 15 | 16 | def get_test_timelapses_raw() -> list[list[tuple[str, dict[str, float]]]]: 17 | """ 18 | Get the raw data of word vectors from test_data submodule 19 | 20 | :return: The list of raw data of word vector timelapses 21 | :rtype: List[Tuple[str, Dict[str, float]]] 22 | """ 23 | 24 | output = [] 25 | 26 | # from Elon Musk's tweets 27 | with open( 28 | Path.joinpath( 29 | LIBRARY_DIR.parent, 30 | "tests/test_data/ElonMusk/wordvector_timelapse_elon.json", 31 | ), 32 | "r", 33 | ) as f: 34 | output.append(json.load(f)) 35 | 36 | return output 37 | 38 | 39 | raw_timelapses_test = get_test_timelapses_raw() 40 | """ 41 | The raw data of word vector timelapses got from test_data submodule 42 | :rtype: List[List[Tuple[str, Dict[str, float]]]] 43 | """ 44 | 45 | timelapses_test = [] 46 | """ 47 | The inner class data of word vector timelapses got from test_data submodule 48 | :rtype: List[TimelapseWordVector] 49 | """ 50 | for raw_timelapse in raw_timelapses_test: 51 | timelapse = TimelapseWordVector.convert_from_dicts_list(raw_timelapse) 52 | timelapses_test.append(timelapse) 53 | 54 | 55 | # test this module 56 | def test_module(): 57 | assert get_test_timelapses_raw() is not None 58 | assert len(timelapses_test) == 1 59 | -------------------------------------------------------------------------------- /tests/Auto/Animator/AnimationIntegrator_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | import subprocess 4 | from pathlib import Path 5 | from AnimatedWordCloud.Utils.Data import ( 6 | AllocationTimelapse, 7 | AllocationInFrame, 8 | ) 9 | from AnimatedWordCloud.Utils.Consts import ( 10 | DEFAULT_ENG_FONT_PATH, 11 | ) 12 | from AnimatedWordCloud.Animator.AnimationIntegrator import integrate_images 13 | from AnimatedWordCloud.Animator.ImageCreator import create_images 14 | from AnimatedWordCloud.Utils import Config 15 | 16 | DIR = Path(__file__).parent 17 | 18 | 19 | def test_integrateimages(): 20 | config = Config() 21 | image_path0 = os.path.join(DIR, "tests0.png") 22 | image_path1 = os.path.join(DIR, "tests1.png") 23 | image_paths: list[str] = [image_path0, image_path1] # temporary path 24 | dummy_timelapse = AllocationTimelapse() 25 | dummy_timelapse.add( 26 | "2023_04_01", AllocationInFrame(from_static_allocation=True) 27 | ) 28 | dummy_timelapse.add( 29 | "2023_04_02", AllocationInFrame(from_static_allocation=False) 30 | ) 31 | assert integrate_images(image_paths, dummy_timelapse, config) != None 32 | 33 | 34 | def test_imagecreator_and_integrateimages(): 35 | # test create_images function 36 | config = Config() 37 | position_in_frames = AllocationTimelapse() 38 | allocation_in_frame = AllocationInFrame(from_static_allocation=True) 39 | allocation_in_frame.words = {"word": (30, (50, 50))} # dictionary 40 | position_in_frames.add("2023_04_01", allocation_in_frame) 41 | image_paths = create_images( 42 | position_in_frames, 43 | config, 44 | ) 45 | print(image_paths) 46 | assert integrate_images(image_paths, position_in_frames, config) != None 47 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AnimationIntegrator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Integrates the images into a single video (gif) 8 | """ 9 | 10 | from __future__ import annotations 11 | import os 12 | from PIL import Image 13 | from AnimatedWordCloud.Utils import Config, AllocationTimelapse 14 | 15 | 16 | def integrate_images( 17 | image_paths: list[str], 18 | allocation_timelapse: AllocationTimelapse, 19 | config: Config, 20 | filename: str = "output.gif", 21 | ) -> str: 22 | """ 23 | Create images of each frame 24 | 25 | :param 26 | List[str] image_paths: List of image_paths created by AnimatedWordCloud.Animator.ImageCreator.create_images 27 | :param AllocationTimelapse allocation_timelapse: AllocationTimelapse instance 28 | :param Config config: Config instance 29 | :return: The path of the output animation file 30 | :rtype: str 31 | """ 32 | 33 | if config.verbosity == "debug": 34 | print("Integrating images...") 35 | 36 | # input 37 | gif_images = [Image.open(path) for path in image_paths] 38 | 39 | # output 40 | filepath_output = os.path.join(config.output_path, filename) 41 | 42 | # compute the duration of each frame 43 | durations = [] 44 | for _, allocation_in_frame in allocation_timelapse.timelapse: 45 | if allocation_in_frame.from_static_allocation: 46 | durations.append(config.duration_per_static_frame) 47 | else: 48 | durations.append(config.duration_per_interpolation_frame) 49 | 50 | # save gif 51 | gif_images[0].save( 52 | filepath_output, 53 | save_all=True, 54 | append_images=gif_images[1:], 55 | duration=durations, 56 | loop=0, 57 | ) 58 | 59 | return filepath_output 60 | -------------------------------------------------------------------------------- /tests/Auto/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationCalculator_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Testing the StaticAllocationCalculator module 8 | """ 9 | 10 | 11 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationCalculator import ( 12 | calculate_font_size, 13 | estimate_text_size, 14 | allocate, 15 | allocate_all, 16 | AllocationInFrame, 17 | WordVector, 18 | Config, 19 | ) 20 | from AnimatedWordCloud.Utils import DEFAULT_ENG_FONT_PATH 21 | from tests.TestDataGetter import timelapses_test 22 | 23 | 24 | def test_calculate_font_size(): 25 | # check max 26 | assert calculate_font_size(100, 100, 50, 50, 25) == 50 27 | assert calculate_font_size(1500, 1500, 50, 100, 25) == 100 28 | 29 | # check min 30 | assert calculate_font_size(50, 100, 50, 50, 25) == 25 31 | assert calculate_font_size(50, 1500, 50, 100, 25) == 25 32 | 33 | # check monotone increasing 34 | assert calculate_font_size(6.3, 10.0, 0.0, 1000, 5) >= calculate_font_size( 35 | 3.0, 10.0, 0.0, 1000, 5 36 | ) 37 | assert calculate_font_size(6.3, 10.0, 0.0, 1000, 5) >= calculate_font_size( 38 | 6.0, 10.0, 0.0, 1000, 5 39 | ) 40 | 41 | 42 | def test_estimate_text_size(): 43 | # just a exceessive test 44 | assert estimate_text_size("Hello", 100, DEFAULT_ENG_FONT_PATH)[0] > 50 45 | assert estimate_text_size("Hello", 100, DEFAULT_ENG_FONT_PATH)[1] > 50 46 | 47 | 48 | def test_allocate(): 49 | allocation_before = AllocationInFrame(from_static_allocation=True) 50 | allocation_before.add("test_x", 10, (0, 0)) 51 | allocation_before.add("test_y", 20, (600, 600)) 52 | 53 | word_vector = WordVector() 54 | word_vector.add("test_x", 10) 55 | word_vector.add("test_z", 20) 56 | assert allocate(word_vector, allocation_before, Config()) is not None 57 | 58 | 59 | def test_allocate_all(): 60 | # make test lighter 61 | config = Config() 62 | config.max_words = 5 63 | assert allocate_all(timelapses_test[0], config) is not None 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Setup script 8 | """ 9 | from setuptools import find_packages, setup 10 | from pathlib import Path 11 | 12 | # get README.md 13 | readme = (Path(__file__).parent / "README.md").read_text(encoding="utf-8") 14 | 15 | 16 | def requirements_from_file(file_name): 17 | return open(file_name).read().splitlines() 18 | 19 | 20 | setup( 21 | name="AnimatedWordCloudTimelapse", 22 | version="1.0.9", 23 | description="Animate a timelapse of word cloud", 24 | long_description=readme, 25 | long_description_content_type="text/markdown", 26 | author="konbraphat51, superhotdogcat", 27 | author_email="konbraphat51@gmail.com, siromisochan@gmail.com", 28 | url="https://github.com/konbraphat51/AnimatedWordCloud/tree/main", 29 | packages=find_packages(exclude=["tests", "Docs"]), 30 | test_suite="tests", 31 | python_requires=">=3.8", 32 | package_data={"AnimatedWordCloud": ["Assets/**"]}, 33 | include_package_data=True, 34 | install_requires=[ 35 | "Pillow==10.3.0", 36 | "numpy", 37 | "matplotlib", 38 | "joblib", 39 | "tqdm", 40 | ], 41 | license="MIT License", 42 | zip_safe=False, 43 | keywords=[ 44 | "NLP", 45 | "World Cloud", 46 | "Animation", 47 | "Natural Language Processing", 48 | "video", 49 | "Visualization", 50 | "Data Science", 51 | ], 52 | classifiers=[ 53 | "Development Status :: 1 - Planning", 54 | "Programming Language :: Python :: 3", 55 | "Intended Audience :: Developers", 56 | "Intended Audience :: Information Technology", 57 | "Intended Audience :: Science/Research", 58 | "License :: OSI Approved :: MIT License", 59 | "Topic :: Multimedia :: Video", 60 | "Topic :: Scientific/Engineering :: Information Analysis", 61 | "Topic :: Text Processing", 62 | ], 63 | entry_points={ 64 | # "console_scripts": [ 65 | # ], 66 | }, 67 | project_urls={ 68 | "GitHub Repository": "https://github.com/konbraphat51/AnimatedWordCloud" 69 | }, 70 | ) 71 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Utils/Collisions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Collision detection functions 8 | """ 9 | 10 | from __future__ import annotations 11 | from typing import Iterable 12 | from AnimatedWordCloud.Utils.Vector import Vector 13 | from AnimatedWordCloud.Utils.Data.Rect import Rect 14 | 15 | 16 | def is_point_hitting_rects( 17 | point: tuple[int, int] | Vector, rects: Iterable[Rect] 18 | ) -> tuple[bool, Rect]: 19 | """ 20 | Check if the point is hitting any of the rects given. 21 | 22 | :param Tuple[int,int]|Vector point: Point to check 23 | :param Iterable[Rect] rects: Rectangles to check 24 | :return: (Is hitting, Rect that is hitting) 25 | :rtype: Tuple[bool, Rect] 26 | """ 27 | for rect in rects: 28 | if is_point_hitting_rect(point, rect): 29 | return (True, rect) 30 | 31 | return (False, None) 32 | 33 | 34 | def is_point_hitting_rect(point: tuple[int, int] | Vector, rect: Rect) -> bool: 35 | """ 36 | Check if the point is hitting the single rect given. 37 | 38 | :param Tuple[int,int]|Vector point: Point to check 39 | :param Rect rect: Rectangle to check 40 | :return: Is hitting 41 | :rtype: bool 42 | """ 43 | 44 | if point.__class__ == tuple: 45 | point = Vector(point) 46 | 47 | return ( 48 | rect.left_top[0] < point.x < rect.right_bottom[0] 49 | and rect.left_top[1] < point.y < rect.right_bottom[1] 50 | ) 51 | 52 | 53 | def is_rect_hitting_rect(rect0: Rect, rect1: Rect) -> bool: 54 | """ 55 | Check if 2 rects hitting 56 | 57 | Check the collision by AABB algorithm 58 | 59 | :param Rect rect0: Rect to check 60 | :param Rect rect1: Rect to check 61 | :return: Whether 2 rects are hitting 62 | :rtype: bool 63 | """ 64 | 65 | return not ( 66 | rect0.right_bottom[0] < rect1.left_top[0] 67 | or rect0.left_top[0] > rect1.right_bottom[0] 68 | or rect0.right_bottom[1] < rect1.left_top[1] 69 | or rect0.left_top[1] > rect1.right_bottom[1] 70 | ) 71 | 72 | 73 | def is_rect_hitting_rects(rect: Rect, rects: Iterable[Rect]) -> bool: 74 | """ 75 | Check if the rect is hitting any of the rects given. 76 | 77 | :param Rect rect: Rect to check 78 | :param Iterable[Rect] rects: Rectangles to check 79 | :return: Is hitting 80 | :rtype: bool 81 | """ 82 | for rect_to_check in rects: 83 | if is_rect_hitting_rect(rect, rect_to_check): 84 | return True 85 | 86 | return False 87 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Utils/Data/AllocationData.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Classes that includes the data of size and position of each words 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | 13 | class AllocationInFrame: 14 | """ 15 | Positions and size of each words in a frame 16 | """ 17 | 18 | def __init__(self, from_static_allocation: bool) -> None: 19 | """ 20 | Prepare empty data 21 | 22 | :param bool flag_static: Whether the frame is static or not 23 | """ 24 | 25 | self.from_static_allocation = from_static_allocation 26 | 27 | # word -> (font size, left-top position) 28 | self.words: dict[str, tuple[float, tuple[float, float]]] = {} 29 | 30 | def add( 31 | self, word: str, font_size: float, left_top: tuple[float, float] 32 | ) -> None: 33 | """ 34 | Add a word 35 | 36 | :param str word: Word to add 37 | :param float font_size: Font size of the word 38 | :param tuple[float, float] left_top: Left-top position of the word 39 | :rtype: None 40 | """ 41 | 42 | self.words[word] = (font_size, left_top) 43 | 44 | def __getitem__(self, word: str) -> tuple[float, tuple[float, float]]: 45 | """ 46 | Get the data of the word 47 | 48 | :param str word: Word to get 49 | :return: (font size, left-top position) 50 | :rtype: tuple[float, tuple[float, float]] 51 | """ 52 | 53 | return self.words[word] 54 | 55 | 56 | class AllocationTimelapse: 57 | """ 58 | Timelapse of positions and size of each words 59 | """ 60 | 61 | def __init__(self) -> None: 62 | """ 63 | Prepare empty data 64 | """ 65 | 66 | # (time_name, AllocationInFrame) 67 | self.timelapse: list[tuple(str, AllocationInFrame)] = [] 68 | 69 | def add( 70 | self, time_name: str, allocation_in_frame: AllocationInFrame 71 | ) -> None: 72 | """ 73 | Add a frame of allocation data 74 | 75 | :param str time_name: Name of the time 76 | :param AllocationInFrame allocation_in_frame: Allocation data of the frame 77 | :rtype: None 78 | """ 79 | 80 | # add 81 | # ensure time_name is string 82 | self.timelapse.append((str(time_name), allocation_in_frame)) 83 | 84 | def get_frame(self, index: int) -> AllocationInFrame: 85 | """ 86 | Get the allocation data of the frame 87 | 88 | :param int index: Index of the frame 89 | :return: Allocation data of the frame 90 | :rtype: AllocationInFrame 91 | """ 92 | 93 | return self.timelapse[index][1] 94 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationStrategies/RandomAllocation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | First static allocation strategy. 8 | 9 | Randomly allocate words AROUND the image. 10 | Words are put on randomly 11 | on a circle, whose center is the center of the image, 12 | and whose radius is 13 | (the diagonal length of the image + the diagonal length of the word)/2 14 | which don't let the word overlap with the image in this frame. 15 | 16 | This is used for the very first frame, before the first timelapse. 17 | """ 18 | 19 | from __future__ import annotations 20 | from typing import Iterable 21 | from random import random 22 | from math import pi, cos, sin 23 | from AnimatedWordCloud.Utils import Word, AllocationInFrame 24 | 25 | 26 | def allocate_randomly( 27 | words: Iterable[Word], image_width: int, image_height: int 28 | ) -> AllocationInFrame: 29 | """ 30 | Allocate the words 31 | 32 | :param Iterable[Word] words: The words to allocate 33 | :return: Allocation data of the frame 34 | :rtype: AllocationInFrame 35 | """ 36 | 37 | output = AllocationInFrame(from_static_allocation=True) 38 | 39 | for word in words: 40 | # put 41 | text_lefttop_position = put_randomly( 42 | image_width, image_height, word.text_size 43 | ) 44 | 45 | # allocate in the output 46 | output.add(word.text, word.font_size, text_lefttop_position) 47 | 48 | return output 49 | 50 | 51 | def put_randomly( 52 | image_width: int, image_height: int, word_size: tuple[int, int] 53 | ) -> tuple[int, int]: 54 | """ 55 | Randomly allocate word AROUND the image. 56 | 57 | Words are put on randomly 58 | on a circle, whose center is the center of the image, 59 | and whose radius is 60 | (the diagonal length of the image + the diagonal length of the word)/2 61 | which don't let the word overlap with the image in this frame. 62 | 63 | :param int image_width: Width of the image 64 | :param int image_height: Height of the image 65 | :param tuple[int, int] word_size: Size of the word 66 | :return: Left-top position of the word 67 | :rtype: tuple[int, int] 68 | """ 69 | # calculate the radius of the circle 70 | image_diagnal = (image_width**2 + image_height**2) ** 0.5 71 | text_diagnal = (word_size[0] ** 2 + word_size[1] ** 2) ** 0.5 72 | radius = (image_diagnal + text_diagnal) / 2 73 | 74 | # randomly choose where on the circle to put 75 | angle = random() * 2 * pi 76 | image_center_x = image_width / 2 77 | image_center_y = image_height / 2 78 | x = image_center_x + radius * cos(angle) 79 | y = image_center_y + radius * sin(angle) 80 | text_lefttop_position = ( 81 | x - word_size[0] / 2, 82 | y - word_size[1] / 2, 83 | ) 84 | 85 | return text_lefttop_position 86 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/Animator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Heart of animation flow 8 | Get the input and returns the output, 9 | calling each modules 10 | """ 11 | 12 | from __future__ import annotations 13 | from typing import Iterable 14 | from AnimatedWordCloud.Utils import Config, TimelapseWordVector 15 | from AnimatedWordCloud.Animator.AllocationCalculator import allocate 16 | from AnimatedWordCloud.Animator.ImageCreator import create_images 17 | from AnimatedWordCloud.Animator.AnimationIntegrator import integrate_images 18 | 19 | 20 | def animate( 21 | word_vector_timelapse: Iterable[tuple[str, dict[str, float]]], 22 | config: Config = None, 23 | output_filename: str = "output.gif", 24 | ) -> str: 25 | """ 26 | Create an animation of word cloud, 27 | from the timelapse data of words vectors 28 | 29 | This function will call each modules in the right order, 30 | and return the animation path 31 | 32 | :param Iterable[tuple[str, dict[str, float]]] word_vector_timelapse: 33 | Timelapse data of word vectors. 34 | The data structure is a list of tuples, 35 | which includes "name of the time(str)" and "word vector(Dict[str, float])" 36 | :param Config config: Configuration of the animation. If None, default config will be used. 37 | :param str output_filename: Filename of the animation file. 38 | :return: The path of the animation file. 39 | :rtype: str 40 | """ 41 | 42 | # use default config if not specified 43 | if config is None: 44 | config = Config() 45 | 46 | # convert data to TimelapseWordVector 47 | timelapse_word_vector = TimelapseWordVector.convert_from_dicts_list( 48 | word_vector_timelapse 49 | ) 50 | 51 | # Calculate allocation 52 | allocation_timelapse = allocate(timelapse_word_vector, config) 53 | 54 | # to images 55 | image_paths = create_images(allocation_timelapse, config) 56 | 57 | # to one animation file 58 | animation_path = integrate_images( 59 | image_paths, allocation_timelapse, config, filename=output_filename 60 | ) 61 | 62 | if config.verbosity == "minor" or config.verbosity == "debug": 63 | _success_message(animation_path) 64 | 65 | return animation_path 66 | 67 | 68 | def _success_message(animation_path: str) -> None: 69 | """ 70 | print success message. 71 | 72 | :param str animation_path: animation saved path 73 | :return: None 74 | :rtype: None 75 | """ 76 | success_message = ( 77 | f"your animation was successfully saved in {animation_path}" 78 | ) 79 | string_frame_over = ( 80 | "-" * (len(success_message) // 2) 81 | + "Success!" 82 | + "-" * (len(success_message) // 2) 83 | ) 84 | string_frame_under = "-" * len(success_message + "Success!") 85 | success_message = " " * 4 + success_message + " " * 4 86 | print(string_frame_over) 87 | print(success_message) 88 | print(string_frame_under) 89 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at the issue. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "dev", "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "dev" ] 20 | schedule: 21 | - cron: '29 12 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | # Runner size impacts CodeQL analysis time. To learn more, please see: 27 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 28 | # - https://gh.io/supported-runners-and-hardware-resources 29 | # - https://gh.io/using-larger-runners 30 | # Consider using larger runners for possible analysis time improvements. 31 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 32 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 33 | permissions: 34 | actions: read 35 | contents: read 36 | security-events: write 37 | 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | language: [ 'python' ] 42 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] 43 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both 44 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 45 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 46 | 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v3 50 | 51 | # Initializes the CodeQL tools for scanning. 52 | - name: Initialize CodeQL 53 | uses: github/codeql-action/init@v2 54 | with: 55 | languages: ${{ matrix.language }} 56 | # If you wish to specify custom queries, you can do so here or in a config file. 57 | # By default, queries listed here will override any specified in a config file. 58 | # Prefix the list here with "+" to use these queries and those in the config file. 59 | 60 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 61 | # queries: security-extended,security-and-quality 62 | 63 | 64 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 65 | # If this step fails, then you should remove it and run the build manually (see below) 66 | - name: Autobuild 67 | uses: github/codeql-action/autobuild@v2 68 | 69 | # ℹ️ Command-line programs to run using the OS shell. 70 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 71 | 72 | # If the Autobuild fails above, remove it and uncomment the following three lines. 73 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 74 | 75 | # - run: | 76 | # echo "Run, Build Application using script" 77 | # ./location_of_script_within_repo/buildscript.sh 78 | 79 | - name: Perform CodeQL Analysis 80 | uses: github/codeql-action/analyze@v2 81 | with: 82 | category: "/language:${{matrix.language}}" 83 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to AnimatedWordCloud 2 | 3 | Greetings, Thanks for your interest in contributing to AnimatedWordCloud. 4 | 5 | ## Status of `dev` branch. 6 | 7 | 8 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/20a71da0d9d841a2af236f6362a08ae7)](https://app.codacy.com/gh/konbraphat51/AnimatedWordCloud/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) 9 | [![unit-test](https://github.com/konbraphat51/AnimatedWordCloud/actions/workflows/python-tester.yml/badge.svg?branch=dev)](https://github.com/konbraphat51/AnimatedWordCloud/actions/workflows/python-tester.yml) 10 | [![Lint](https://github.com/konbraphat51/AnimatedWordCloud/actions/workflows/lint.yml/badge.svg)](https://github.com/konbraphat51/AnimatedWordCloud/actions/workflows/lint.yml) 11 | [![codecov](https://codecov.io/gh/konbraphat51/AnimatedWordCloud/graph/badge.svg?token=4OOX0GSJDJ)](https://codecov.io/gh/konbraphat51/AnimatedWordCloud) 12 | 13 | ## Declear an issue before develop 14 | 15 | We love your contribution, but there is a possibility that somebody else already doing your work, or your plan is unnecessary. Please make an issue and notify us before starting to develop. 16 | 17 | ## Environment 18 | 19 | You need to pip install this library (`dev`branch) and update submodules 20 | 21 | ``` 22 | git clone (your fork) 23 | cd (your fork) 24 | pip install -e . 25 | git submodule init 26 | git submodule update 27 | ``` 28 | 29 | ## Code 30 | 31 | ### Writing Python 32 | 33 | - Follow [PEP8](https://peps.python.org/pep-0008/), you can automatically do this by using [black](https://github.com/psf/black) with `--line-length = 79` 34 | 35 | - Please follow this [naming convention](https://namingconvention.org/python/). For example, global constant variables must be in `ALL_CAPS`; 36 | 37 | 38 | - Variables, classes, modules names should be **nouns**, and functions, methods names should be **verb**. 39 | 40 | - Write your test for your new features in `tests/` directory. 41 | If the output is graphical, show the demonstrating output in the PR. 42 | But also add a test code to see if there is an error. 43 | - Get rid of commented out codes. 44 | - All `#TODO` must be reported at issues. 45 | - Comments cannot be too much. Write what you intended **in English**. 46 | - One module's responsibility (to-do) should be only one. If you are trying to add a method of another functionality, **make a new module** 47 | 48 | ### Commit message 49 | 50 | Please clarify what you did. 51 | Verb at the very front will make it easier to see. 52 | ex) 53 | Refac: clarify commentation 54 | Add: rid duplication of words 55 | 56 | ### PR 57 | 58 | Submit PR to `dev` branch (Not `main` branch!!). 59 | 60 | Submit PR as **draft** for the first. The GitHub Actions will start analyzing your contribution. 61 | 62 | If there is something pointed out from GitHub Actions, please fix it. 63 | 64 | **If all problems are solved, turn your draft PR to open PR, and report as a comment that you are ready to get reviewed** 65 | 66 | ## Updating Pypi 67 | 68 | **Only for maintainers** 69 | 70 | 1. Update version of `README.md` & `setup.py` [[Example Commit]](https://github.com/konbraphat51/AnimatedWordCloud/commit/c886f593d590ebe990cd451c219df3d2733a5a48) 71 | 2. Commit 1. as "UpVer: [version]" to `dev` 72 | 3. Merge to `main` 73 | 4. Access to [Releases](https://github.com/konbraphat51/AnimatedWordCloud/releases) and make a new release with configuration below: 74 | - new tag: vX.X.X (new version) 75 | - Target branch: main 76 | - Release title: version X.X.X(new version) 77 | - Description: what changed 78 | ![image](https://github.com/konbraphat51/AnimatedWordCloud/assets/101827492/1bc79398-5458-4a8b-b03f-26efd51917fe) 79 | 80 | 5. GitHub Actions automatically upload the newest version to PyPI 81 | 82 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Utils/Vector.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Modules for 2D vector calculation 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | 13 | class Vector: 14 | """ 15 | Class for 2D vector calculation 16 | """ 17 | 18 | def __init__(self, *args: float | tuple[float, float]) -> None: 19 | """ 20 | Initialize Vector class. 21 | 22 | :param args: x and y or tuple of x and y 23 | input as Vector(x, y) or Vector((x, y)) 24 | """ 25 | 26 | # if the input is tuple... 27 | if args[0].__class__ == tuple: 28 | # ...ensuring (float, float) 29 | if len(args[0]) != 2: 30 | raise ValueError("Tuple must have 2 elements") 31 | 32 | # if the value was not a number, this intends to raise an error 33 | self.x = float(args[0][0]) 34 | self.y = float(args[0][1]) 35 | elif len(args) == 2: 36 | # ...x, y are seperatedly input 37 | 38 | # if the value was not a number, this intends to raise an error 39 | self.x = float(args[0]) 40 | self.y = float(args[1]) 41 | else: 42 | raise ValueError("Invalid Vector input") 43 | 44 | def __add__(self, other: Vector) -> Vector: 45 | """ 46 | Sum of two vectors. 47 | 48 | :return: sum of two vectors 49 | :rtype: Vector 50 | """ 51 | return Vector(self.x + other.x, self.y + other.y) 52 | 53 | def __sub__(self, other: Vector) -> Vector: 54 | """ 55 | Difference of two vectors. 56 | 57 | :return: difference of two vectors 58 | :rtype: Vector 59 | """ 60 | return Vector(self.x - other.x, self.y - other.y) 61 | 62 | def __mul__(self, other: float) -> Vector: 63 | """ 64 | Product of vector and scalar. 65 | 66 | :return: product of vector and scalar 67 | :rtype: Vector 68 | """ 69 | return Vector(self.x * other, self.y * other) 70 | 71 | def __truediv__(self, other: float) -> Vector: 72 | """ 73 | Division of vector and scalar. 74 | 75 | :return: division of vector and scalar 76 | :rtype: Vector 77 | """ 78 | return Vector(self.x / other, self.y / other) 79 | 80 | def __getitem__(self, index: int) -> float: 81 | """ 82 | Get item by index. 83 | 84 | :return: x if index is 0, y if index is 1 85 | :rtype: float 86 | """ 87 | if index == 0: 88 | return self.x 89 | elif index == 1: 90 | return self.y 91 | else: 92 | raise IndexError(f"Index {index} is out of range") 93 | 94 | def __str__(self) -> str: 95 | """ 96 | Convert to string. 97 | 98 | :return: string representation of the vector 99 | :rtype: str 100 | """ 101 | return f"({self.x}, {self.y})" 102 | 103 | def clone(self) -> Vector: 104 | """ 105 | Clone this vector. 106 | 107 | So as modifying the output doesn't affect the original vector. 108 | 109 | :return: Cloned vector 110 | :rtype: Vector 111 | """ 112 | return Vector(self.x, self.y) 113 | 114 | def convert_to_tuple(self) -> tuple[float, float]: 115 | """ 116 | Convert to tuple 117 | 118 | :return: tuple of x and y 119 | :rtype: tuple[float, float] 120 | """ 121 | return (self.x, self.y) 122 | 123 | def cross(vec0: Vector, vec1: Vector) -> float: 124 | """ 125 | Cross product of two vectors. 126 | This is static method. 127 | 128 | :param Vector vec0: Vector 0 129 | :param Vector vec1: Vector 1 130 | :return: Cross product of two vectors 131 | :rtype: float 132 | """ 133 | return vec0.x * vec1.y - vec0.y * vec1.x 134 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationStrategies/StaticAllocationStrategy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Base class for static allocation strategies 8 | """ 9 | 10 | from typing import Iterable 11 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies.RandomAllocation import ( 12 | put_randomly, 13 | ) 14 | from AnimatedWordCloud.Utils import Word, AllocationInFrame, Config 15 | 16 | 17 | class StaticAllocationStrategy: 18 | """ 19 | Base class for static allocation strategies 20 | """ 21 | 22 | def __init__(self, config: Config): 23 | self.config = config 24 | 25 | def allocate( 26 | self, words: Iterable[Word], allocation_before: AllocationInFrame 27 | ) -> AllocationInFrame: 28 | """ 29 | Allocate the words 30 | 31 | This is abstract method. 32 | 33 | :param Iterable[Word] words: The words to allocate 34 | :param AllocationInFrame allocation_before: The allocation data of the previous frame 35 | """ 36 | 37 | raise NotImplementedError 38 | 39 | def add_missing_word_to_previous_frame( 40 | self, frame_previous: AllocationInFrame, words_current: Iterable[Word] 41 | ) -> None: 42 | """ 43 | Add word in instance of the previous frame 44 | 45 | Find words going to be putted in current frame 46 | but not in previous frame. 47 | Add the missing words randomly put the word in the previous frame. 48 | The putting algorithm is the same as the one in `RandomAllocation`. 49 | This is intended to let appearing words appear smoothly. 50 | 51 | :param AllocationInFrame frame_previous: 52 | The allocation data of the previous frame 53 | :param Iterable[Word] words_current: 54 | The words to be putted in the current frame 55 | :rtype: None 56 | """ 57 | 58 | # find missing words 59 | words_previous = set(frame_previous.words.keys()) 60 | word_texts_current = [] 61 | words_size = {} 62 | words_font_size = {} 63 | for word in words_current: 64 | if word.text not in words_current: 65 | word_texts_current.append(word.text) 66 | words_size[word.text] = word.text_size 67 | words_font_size[word.text] = word.font_size 68 | missing_words = set(word_texts_current) - words_previous 69 | 70 | # add missing words 71 | for word in missing_words: 72 | # put randomly 73 | text_lefttop_position = put_randomly( 74 | self.config.image_width, 75 | self.config.image_height, 76 | words_size[word], 77 | ) 78 | 79 | # allocate in the output 80 | frame_previous.add( 81 | word, words_font_size[word], text_lefttop_position 82 | ) 83 | 84 | def add_missing_word_from_previous_frame( 85 | self, 86 | frame_previous: AllocationInFrame, 87 | frame_current: AllocationInFrame, 88 | ) -> None: 89 | """ 90 | Add missing word from the previous frame 91 | to the instance of the current frame 92 | 93 | Find words existed in previous frame but not in current, 94 | and add them to the current frame. 95 | Added words are putted randomly, same as `RandomAllocation`. 96 | This must be called after all words are allocated in the current frame. 97 | This is intended to let disappearing words disappear smoothly. 98 | 99 | :param AllocationInFrame frame_previous: 100 | The allocation data of the previous frame 101 | :param AllocationInFrame frame_current: 102 | The allocation data of the current frame 103 | :rtype: None 104 | """ 105 | 106 | # find missing words 107 | words_previous = set(frame_previous.words.keys()) 108 | words_current = set(frame_current.words.keys()) 109 | words_missing = words_previous - words_current 110 | 111 | # add missing words 112 | for word in words_missing: 113 | estimated_size = ( 114 | len(word) * frame_previous[word][0], 115 | frame_previous[word][0], 116 | ) 117 | lefttop_position = put_randomly( 118 | self.config.image_width, 119 | self.config.image_height, 120 | estimated_size, 121 | ) 122 | frame_current.add(word, frame_previous[word][0], lefttop_position) 123 | -------------------------------------------------------------------------------- /tests/Auto/Utils/TimelapseWordVector_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Testing the TimelapsedWordVector classes 8 | """ 9 | 10 | 11 | from AnimatedWordCloud.Utils import ( 12 | WordVector, 13 | TimeFrame, 14 | TimelapseWordVector, 15 | ) 16 | 17 | 18 | def test_wordvector(): 19 | # test add 20 | instance = WordVector() 21 | 22 | instance.add("test", 1) 23 | instance.add("test2", 4) 24 | instance.add("test3", 3) 25 | instance.add("test4", 2) 26 | 27 | assert instance._word_bisect == [ 28 | (-4, "test2"), 29 | (-3, "test3"), 30 | (-2, "test4"), 31 | (-1, "test"), 32 | ] 33 | assert instance._word_dictionary == { 34 | "test": 1, 35 | "test2": 4, 36 | "test3": 3, 37 | "test4": 2, 38 | } 39 | 40 | # test add_multiple 41 | instance = WordVector() 42 | 43 | instance.add_multiple( 44 | [("test", 1), ("test2", 4), ("test3", 3), ("test4", 2)] 45 | ) 46 | 47 | assert instance._word_bisect == [ 48 | (-4, "test2"), 49 | (-3, "test3"), 50 | (-2, "test4"), 51 | (-1, "test"), 52 | ] 53 | assert instance._word_dictionary == { 54 | "test": 1, 55 | "test2": 4, 56 | "test3": 3, 57 | "test4": 2, 58 | } 59 | 60 | # test get ranking 61 | assert instance.get_ranking(0, 2) == [("test2", 4), ("test3", 3)] 62 | assert instance.get_ranking(1, 3) == [("test3", 3), ("test4", 2)] 63 | assert instance.get_ranking(1, -1) == [ 64 | ("test3", 3), 65 | ("test4", 2), 66 | ("test", 1), 67 | ] 68 | assert instance.get_ranking(1, 100) == [ 69 | ("test3", 3), 70 | ("test4", 2), 71 | ("test", 1), 72 | ] 73 | 74 | # test get_weight 75 | assert instance.get_weight("test") == 1 76 | 77 | # test convert_from_dict 78 | instance = WordVector.convert_from_dict( 79 | {"test": 1, "test2": 4, "test3": 3, "test4": 2} 80 | ) 81 | 82 | assert instance._word_bisect == [ 83 | (-4, "test2"), 84 | (-3, "test3"), 85 | (-2, "test4"), 86 | (-1, "test"), 87 | ] 88 | assert instance._word_dictionary == { 89 | "test": 1, 90 | "test2": 4, 91 | "test3": 3, 92 | "test4": 2, 93 | } 94 | 95 | 96 | def test_timeframe(): 97 | # demo word vector 98 | test_dict = {"test": 1, "test2": 4, "test3": 3, "test4": 2} 99 | word_vector = WordVector.convert_from_dict(test_dict) 100 | 101 | # test constructor 102 | instance = TimeFrame("test_time", word_vector) 103 | 104 | assert instance.time_name == "test_time" 105 | assert instance.word_vector.get_ranking(0, -1) == [ 106 | ("test2", 4), 107 | ("test3", 3), 108 | ("test4", 2), 109 | ("test", 1), 110 | ] 111 | 112 | # test convert_from_dict 113 | assert TimeFrame.convert_from_dict( 114 | "test_time", test_dict 115 | ).word_vector.get_ranking(0, -1) == [ 116 | ("test2", 4), 117 | ("test3", 3), 118 | ("test4", 2), 119 | ("test", 1), 120 | ] 121 | 122 | assert ( 123 | TimeFrame.convert_from_dict("test_time", test_dict).time_name 124 | == "test_time" 125 | ) 126 | 127 | # test convert_from_tup_dict 128 | assert TimeFrame.convert_from_tup_dict( 129 | ["test_time", test_dict] 130 | ).word_vector.get_ranking(0, -1) == [ 131 | ("test2", 4), 132 | ("test3", 3), 133 | ("test4", 2), 134 | ("test", 1), 135 | ] 136 | 137 | 138 | def test_timelapsewordvector(): 139 | # demo word vector 140 | test_dict0 = {"test": 1, "test2": 4, "test3": 3, "test4": 2} 141 | test_dict1 = {"test": 4, "test2": 1, "test3": -2, "test4": 10} 142 | word_vector0 = WordVector.convert_from_dict(test_dict0) 143 | word_vector1 = WordVector.convert_from_dict(test_dict1) 144 | 145 | time_frame0 = TimeFrame("test_time", word_vector0) 146 | time_frame1 = TimeFrame("test_time", word_vector1) 147 | 148 | # test add_time_frame, __getitem__ 149 | instance = TimelapseWordVector() 150 | instance.add_time_frame(time_frame0) 151 | instance.add_time_frame(time_frame1) 152 | 153 | assert instance[0].word_vector.get_ranking(0, -1) == [ 154 | ("test2", 4), 155 | ("test3", 3), 156 | ("test4", 2), 157 | ("test", 1), 158 | ] 159 | 160 | assert instance[0].time_name == "test_time" 161 | 162 | assert instance[1].word_vector.get_ranking(0, -1) == [ 163 | ("test4", 10), 164 | ("test", 4), 165 | ("test2", 1), 166 | ("test3", -2), 167 | ] 168 | 169 | # test __len__ 170 | assert len(instance) == 2 171 | 172 | # test convert_from_dicts_list 173 | dicts_list = [("time0", test_dict0), ("test_dict1", test_dict1)] 174 | 175 | assert TimelapseWordVector.convert_from_dicts_list(dicts_list)[ 176 | 0 177 | ].word_vector.get_ranking(0, -1) == [ 178 | ("test2", 4), 179 | ("test3", 3), 180 | ("test4", 2), 181 | ("test", 1), 182 | ] 183 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/ImageCreator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Create images of each frame 8 | """ 9 | 10 | 11 | from __future__ import annotations 12 | import os 13 | import hashlib 14 | import numpy as np 15 | import matplotlib.pyplot as plt 16 | import joblib 17 | from PIL import Image, ImageDraw, ImageFont 18 | from AnimatedWordCloud.Utils import ( 19 | ensure_directory_exists, 20 | Config, 21 | AllocationTimelapse, 22 | AllocationInFrame, 23 | ) 24 | 25 | 26 | class colormap_color_func(object): 27 | # https://github.com/amueller/word_cloud/blob/main/wordcloud/wordcloud.py#L91 28 | """Color func created from matplotlib colormap. 29 | 30 | :param colormap : string or matplotlib colormap 31 | Colormap to sample from 32 | """ 33 | 34 | def __init__(self, color_map="dark2"): 35 | self.color_map = plt.get_cmap(name=color_map) 36 | 37 | def __call__( 38 | self, word: str, font_size, position, random_state=None, **kwargs 39 | ): 40 | """ 41 | To maintain each word's color 42 | making a word -> color surjective function 43 | """ 44 | # calculate MD5 hash 45 | md5_hash = hashlib.md5(word.encode()).hexdigest() 46 | # transform hexadecimal into decimal 47 | decimal_value = int(md5_hash, 16) 48 | # Normalize from 0 to 1 value 49 | normalized_value = decimal_value / (16**32 - 1) 50 | r, g, b, _ = np.maximum( 51 | 0, 255 * np.array(self.color_map(normalized_value)) 52 | ) 53 | return "rgb({:.0f}, {:.0f}, {:.0f})".format(r, g, b) 54 | 55 | 56 | def create_image( 57 | allocation_in_frame: AllocationInFrame, 58 | config: Config, 59 | frame_number: int, 60 | time_name: str, 61 | color_func=None, 62 | ) -> tuple[int, str]: 63 | """ 64 | Create image of a frame 65 | 66 | :param AllocationInFrame allocation_in_frame: Position/size data of a video frame. 67 | :param Config config: Config instance 68 | :param int frame_number: Number of the frame. Used for filename 69 | :param str time_name: Name of the time. Used for time stamp 70 | :param object color_func: Custom function for color mapping, default is None. 71 | :return: (frame_number, save_path) 72 | :rtype: tuple[int, str] 73 | """ 74 | if color_func is None: 75 | color_func = colormap_color_func(config.color_map) 76 | 77 | image = Image.new( 78 | "RGB", 79 | (config.image_width, config.image_height), 80 | config.background_color, 81 | ) 82 | draw = ImageDraw.Draw(image) 83 | 84 | # Draw all words 85 | allocation_in_frame_word_dict = allocation_in_frame.words 86 | for word, position in allocation_in_frame_word_dict.items(): 87 | font_size = position[0] 88 | (x, y) = position[1] 89 | font = ImageFont.truetype(config.font_path, font_size) 90 | draw.text( 91 | (x, y), 92 | word, 93 | fill=color_func(word=word, font_size=font_size, position=(x, y)), 94 | font=font, 95 | ) 96 | 97 | # draw time stamp 98 | if config.drawing_time_stamp: 99 | font_size = config.time_stamp_font_size 100 | font = ImageFont.truetype(config.font_path, font_size) 101 | draw.text( 102 | config.time_stamp_position, 103 | time_name, 104 | fill=config.time_stamp_color, 105 | font=font, 106 | ) 107 | 108 | # save the image 109 | filename = f"{config.intermediate_frames_id}_{frame_number}.png" 110 | save_path = os.path.join(config.output_path, filename) 111 | image.save(save_path) 112 | 113 | return (frame_number, save_path) 114 | 115 | 116 | def create_images( 117 | position_in_frames: AllocationTimelapse, 118 | config: Config, 119 | color_func=None, 120 | ) -> list[str]: 121 | """ 122 | Create images of each frame 123 | 124 | :param AllocationTimelapse position_in_frames: List of position/size data of each video frame. 125 | :param Config config: Config instance 126 | :param object color_func: Custom function for color mapping, default is None. 127 | :return: The path of the images. The order of the list is the same as the order of the input. 128 | :rtype: list[str] 129 | """ 130 | 131 | if config.verbosity in ["debug"]: 132 | print("Creating images of each frame...") 133 | 134 | ensure_directory_exists(config.output_path) 135 | 136 | image_paths = [] 137 | 138 | verbosity = 0 139 | if config.verbosity in ["debug"]: 140 | verbosity = 5 141 | 142 | # create images of each frame 143 | result = joblib.Parallel(n_jobs=-1, verbose=verbosity)( 144 | joblib.delayed(create_image)( 145 | allocation_in_frame=allocation_in_frame, 146 | config=config, 147 | frame_number=frame_number, 148 | color_func=color_func, 149 | time_name=time_name, 150 | ) 151 | for frame_number, (time_name, allocation_in_frame) in enumerate( 152 | position_in_frames.timelapse 153 | ) 154 | ) 155 | 156 | # sort by frame number (ascending) 157 | result.sort(key=lambda x: x[0]) 158 | 159 | # get only the path 160 | image_paths = [path for _, path in result] 161 | 162 | return image_paths 163 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationCalculator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Calculate allocation of each words in each static time 8 | """ 9 | 10 | from __future__ import annotations 11 | import numpy as np 12 | from tqdm import tqdm 13 | from PIL import Image, ImageFont, ImageDraw 14 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies import ( 15 | MagneticAllocation, 16 | allocate_randomly, 17 | ) 18 | from AnimatedWordCloud.Utils import ( 19 | WordVector, 20 | TimelapseWordVector, 21 | AllocationInFrame, 22 | AllocationTimelapse, 23 | Word, 24 | Config, 25 | ) 26 | 27 | 28 | def allocate( 29 | word_vector: WordVector, 30 | allocation_before: AllocationInFrame, 31 | config: Config, 32 | ) -> AllocationInFrame: 33 | """ 34 | Calculate allocation of each words in each static time 35 | 36 | :param WordVector word_vector: The word vector 37 | :param AllocationInFrame allocation_before: Allocation data of the previous frame 38 | :param Config config: Config instance 39 | :return: Allocation data of the frame 40 | :rtype: AllocationInFrame 41 | """ 42 | 43 | word_weights = word_vector.get_ranking(0, config.max_words) 44 | 45 | words: list[Word] = [] 46 | 47 | # get attributes for each words, 48 | # and save them as Word instances 49 | for word_raw, weight in word_weights: 50 | # get size 51 | font_size = calculate_font_size( 52 | weight, 53 | word_weights[0][1], # max weight 54 | word_weights[-1][1], # min weight 55 | config.max_font_size, 56 | config.min_font_size, 57 | ) 58 | text_size = estimate_text_size(word_raw, font_size, config.font_path) 59 | 60 | # make instance 61 | word = Word(word_raw, weight, font_size, text_size) 62 | 63 | # save instance 64 | words.append(word) 65 | 66 | # calculate allocation by selected strategy 67 | if config.allocation_strategy == "magnetic": 68 | allocator = MagneticAllocation(config) 69 | return allocator.allocate(words, allocation_before) 70 | else: 71 | raise ValueError( 72 | "Unknown strategy: {}".format(config.allocation_strategy) 73 | ) 74 | 75 | 76 | def calculate_font_size( 77 | weight: float, 78 | weight_max: float, 79 | weight_min: float, 80 | font_max: int, 81 | font_min: int, 82 | ) -> int: 83 | """ 84 | Evaluate how much font size the word should be 85 | 86 | Simply calculate linearly 87 | 88 | :param float weight: The weight of the word 89 | :param float weight_max: The maximum weight of the word 90 | :param float weight_min: The minimum weight of the word 91 | :param int font_max: The maximum font size 92 | :param int font_min: The minimum font size 93 | :return: The font size estimated 94 | :rtype: int 95 | """ 96 | 97 | # calculate font size linearly 98 | weight_ratio = (weight - weight_min) / (weight_max - weight_min) 99 | font_size = int((font_max - font_min) * weight_ratio + font_min) 100 | 101 | return font_size 102 | 103 | 104 | def estimate_text_size( 105 | word: str, font_size: int, font_path: str 106 | ) -> tuple[int, int]: 107 | """ 108 | Estimate text box size 109 | 110 | Highly depends on the drawing library 111 | 112 | :param str word: The word 113 | :param int font_size: The font size 114 | :param str font_path: The font path 115 | :return: Text box size (x, y) 116 | :rtype: tuple[int, int] 117 | """ 118 | 119 | # according to https://watlab-blog.com/2019/08/27/add-text-pixel/ 120 | 121 | # prepare empty image 122 | image = np.zeros( 123 | (font_size * 2, font_size * (len(word) + 1), 3), dtype=np.uint8 124 | ) 125 | font = ImageFont.truetype(font_path, font_size) 126 | image = Image.fromarray(image) 127 | draw = ImageDraw.Draw(image) 128 | 129 | # get size 130 | w = draw.textlength(word, font=font, font_size=font_size) 131 | h = font_size 132 | 133 | return (w, h) 134 | 135 | 136 | def allocate_all( 137 | timelapse: TimelapseWordVector, config: Config 138 | ) -> AllocationTimelapse: 139 | """ 140 | Calculate allocation of each words in each static time 141 | 142 | :param TimelapseWordVector timelapse: The timelapse word vector 143 | :param Config config: Config instance 144 | :return: Allocation data of all the frames 145 | :rtype: AllocationTimelapse 146 | """ 147 | 148 | times = len(timelapse) 149 | 150 | allocation_timelapse = AllocationTimelapse() 151 | 152 | # first frame 153 | first_frame = _allocate_first_frame(timelapse[0].word_vector, config) 154 | allocation_timelapse.add(config.starting_time_stamp, first_frame) 155 | 156 | # verbose for iteration 157 | if config.verbosity in ["debug", "minor"]: 158 | print("Start static-allocation iteration...") 159 | iterator = tqdm(range(times)) 160 | else: 161 | iterator = range(times) 162 | 163 | # calculate allocation for each frame 164 | for cnt in iterator: 165 | allocation = allocate( 166 | timelapse[cnt].word_vector, 167 | allocation_timelapse.get_frame( 168 | cnt 169 | ), # first added -> cnt; one before 170 | config, 171 | ) 172 | allocation_timelapse.add(timelapse[cnt].time_name, allocation) 173 | 174 | return allocation_timelapse 175 | 176 | 177 | def _allocate_first_frame( 178 | word_vector: WordVector, config: Config 179 | ) -> AllocationInFrame: 180 | """ 181 | Calculate allocation of the first frame 182 | 183 | :param WordVector word_vector: The word vector 184 | :param Config config: Config instance 185 | :return: Allocation data of the frame 186 | :rtype: AllocationInFrame 187 | """ 188 | 189 | words_tup = word_vector.get_ranking(0, config.max_words) 190 | words = [] # Word instances 191 | 192 | # get attributes for each words, 193 | # and save them as Word instances 194 | for word_raw, weight in words_tup: 195 | # minimum font size for the first frame 196 | text_size = estimate_text_size( 197 | word_raw, config.min_font_size, config.font_path 198 | ) 199 | 200 | # make instance 201 | word = Word(word_raw, weight, config.min_font_size, text_size) 202 | 203 | # save instance 204 | words.append(word) 205 | 206 | # allocate randomly 207 | return allocate_randomly(words, config.image_width, config.image_height) 208 | -------------------------------------------------------------------------------- /tests/Auto/Animator/AllocationCalculator/AnimatedAllocationCalculator_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import numpy as np 3 | from AnimatedWordCloud.Utils import ( 4 | AllocationTimelapse, 5 | AllocationInFrame, 6 | Config, 7 | ) 8 | from AnimatedWordCloud.Animator.AllocationCalculator.AnimatetdAllocationCalculator import ( 9 | _get_setdiff, 10 | _add_key_in_allocation_frame, 11 | _calc_added_frame, 12 | _get_interpolated_frames, 13 | animated_allocate, 14 | ) 15 | 16 | 17 | def get_setdiff_test(): 18 | allocationframe1 = AllocationInFrame(from_static_allocation=True) 19 | allocationframe2 = AllocationInFrame(from_static_allocation=True) 20 | allocationframe1.add("apple", 10, (10, 10)) 21 | allocationframe1.add("banana", 10, (10, 10)) 22 | allocationframe1.add("grape", 10, (10, 10)) 23 | allocationframe2.add("apple", 10, (10, 10)) 24 | allocationframe2.add("banana", 10, (10, 10)) 25 | allocationframe2.add("orange", 10, (10, 10)) 26 | from_words_to_be_added_key, to_words_to_be_added_key = _get_setdiff( 27 | allocationframe1, allocationframe2 28 | ) 29 | assert from_words_to_be_added_key == ["orange"] 30 | assert to_words_to_be_added_key == ["grape"] 31 | 32 | 33 | def add_key_in_allocation_frame_test(): 34 | allocationframe1 = AllocationInFrame(from_static_allocation=True) 35 | allocationframe2 = AllocationInFrame(from_static_allocation=True) 36 | allocationframe1.add("apple", 10, (10, 10)) 37 | allocationframe1.add("banana", 10, (10, 10)) 38 | allocationframe1.add("grape", 10, (10, 10)) 39 | allocationframe2.add("apple", 10, (10, 10)) 40 | allocationframe2.add("banana", 10, (10, 10)) 41 | allocationframe2.add("orange", 10, (10, 10)) 42 | from_words_to_be_added_key, to_words_to_be_added_key = _get_setdiff( 43 | allocationframe1, allocationframe2 44 | ) 45 | from_allocation_frame, to_allocation_frame = _add_key_in_allocation_frame( 46 | allocationframe1, 47 | allocationframe2, 48 | from_words_to_be_added_key, 49 | to_words_to_be_added_key, 50 | ) 51 | assert from_allocation_frame.words == { 52 | "apple": (10, (10, 10)), 53 | "banana": (10, (10, 10)), 54 | "grape": (10, (10, 10)), 55 | "orange": (10, (10, 10)), 56 | } 57 | assert to_allocation_frame.words == { 58 | "apple": (10, (10, 10)), 59 | "banana": (10, (10, 10)), 60 | "orange": (10, (10, 10)), 61 | "grape": (10, (10, 10)), 62 | } 63 | 64 | 65 | def calc_added_value_test(): 66 | allocationframe1 = AllocationInFrame(from_static_allocation=True) 67 | allocationframe2 = AllocationInFrame(from_static_allocation=True) 68 | allocationframe1.add("apple", 10, (10, 10)) 69 | allocationframe1.add("banana", 10, (10, 10)) 70 | allocationframe1.add("grape", 10, (10, 10)) 71 | allocationframe2.add("apple", 20, (20, 30)) 72 | allocationframe2.add("banana", 10, (10, 10)) 73 | allocationframe2.add("orange", 10, (10, 10)) 74 | from_words_to_be_added_key, to_words_to_be_added_key = _get_setdiff( 75 | allocationframe1, allocationframe2 76 | ) 77 | from_allocation_frame, to_allocation_frame = _add_key_in_allocation_frame( 78 | allocationframe1, 79 | allocationframe2, 80 | from_words_to_be_added_key, 81 | to_words_to_be_added_key, 82 | ) 83 | config = Config() 84 | key = "apple" 85 | index = 1 86 | assert index >= 1 87 | n_frames = 1 88 | frame_font_size, frame_x_pos, frame_y_pos = _calc_added_frame( 89 | from_allocation_frame, to_allocation_frame, key, index, config 90 | ) 91 | assert frame_font_size == 15 92 | assert frame_x_pos == 15 93 | assert frame_y_pos == 20 94 | key = "banana" 95 | index = 1 96 | config.n_frames = 20 97 | frame_font_size, frame_x_pos, frame_y_pos = _calc_added_frame( 98 | from_allocation_frame, 99 | to_allocation_frame, 100 | key, 101 | index, 102 | config, 103 | ) 104 | assert frame_font_size == 10 105 | assert frame_x_pos == 10 106 | assert frame_y_pos == 10 107 | 108 | 109 | def get_interpolated_frames_test(): 110 | config = Config() 111 | config.n_frames = 1 112 | from_day = "2024-1-1" 113 | to_day = "2024-1-2" 114 | allocationframe1 = AllocationInFrame(from_static_allocation=True) 115 | allocationframe2 = AllocationInFrame(from_static_allocation=True) 116 | allocationframe1.add("apple", 10, (10, 10)) 117 | allocationframe1.add("banana", 10, (10, 10)) 118 | allocationframe1.add("grape", 10, (10, 10)) 119 | allocationframe2.add("apple", 20, (20, 30)) 120 | allocationframe2.add("banana", 10, (10, 10)) 121 | allocationframe2.add("orange", 10, (10, 10)) 122 | from_words_to_be_added_key, to_words_to_be_added_key = _get_setdiff( 123 | allocationframe1, allocationframe2 124 | ) 125 | from_allocation_frame, to_allocation_frame = _add_key_in_allocation_frame( 126 | allocationframe1, 127 | allocationframe2, 128 | from_words_to_be_added_key, 129 | to_words_to_be_added_key, 130 | ) 131 | 132 | interpolated_frames = _get_interpolated_frames( 133 | from_allocation_frame, to_allocation_frame, from_day, to_day, config 134 | ) 135 | assert interpolated_frames.timelapse[0][0] == "2024-1-1_to_2024-1-2" 136 | assert interpolated_frames.timelapse[0][1].words == { 137 | "apple": (15, (15, 20)), 138 | "banana": (10, (10, 10)), 139 | "grape": (10, (10, 10)), 140 | "orange": (10, (10, 10)), 141 | } 142 | config.n_frames = 2 143 | interpolated_frames = _get_interpolated_frames( 144 | from_allocation_frame, to_allocation_frame, from_day, to_day, config 145 | ) 146 | assert len(interpolated_frames.timelapse) == 2 147 | print(interpolated_frames.timelapse[0][1].words) 148 | print(interpolated_frames.timelapse[1][1].words) 149 | 150 | 151 | def animated_allocate_test(): 152 | static_timelapse = AllocationTimelapse() 153 | from_day = "2024-1-1" 154 | to_day = "2024-1-2" 155 | allocationframe1 = AllocationInFrame(from_static_allocation=True) 156 | allocationframe2 = AllocationInFrame(from_static_allocation=True) 157 | allocationframe1.add("apple", 10, (10, 10)) 158 | allocationframe1.add("banana", 10, (10, 10)) 159 | allocationframe1.add("grape", 10, (10, 10)) 160 | allocationframe2.add("apple", 20, (20, 30)) 161 | allocationframe2.add("banana", 10, (10, 10)) 162 | allocationframe2.add("orange", 10, (10, 10)) 163 | static_timelapse.add(from_day, allocationframe1) 164 | static_timelapse.add(to_day, allocationframe2) 165 | config = Config() 166 | config.n_frames = 1 167 | config.interpolation_method = "linear" 168 | animated_timelapse: AllocationTimelapse = animated_allocate( 169 | static_timelapse, config 170 | ) 171 | assert len(animated_timelapse.timelapse) == 3 172 | config.n_frames = 3 173 | animated_timelapse: AllocationTimelapse = animated_allocate( 174 | static_timelapse, config 175 | ) 176 | assert len(animated_timelapse.timelapse) == 5 177 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Utils/Data/TimelapseWordVector.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Handful class of containing timelapse data of word vectors. 8 | """ 9 | 10 | from __future__ import annotations 11 | from typing import Iterable 12 | import bisect 13 | 14 | 15 | class WordVector: 16 | """ 17 | Contains each word's weight 18 | """ 19 | 20 | def __init__(self) -> None: 21 | """ 22 | Prepare empty data 23 | """ 24 | 25 | # use bisect to easily get the rankings 26 | # 27 | # to order by weight, 28 | # the weight must be the first element of the tuple 29 | # and negate the weight to get the descending order 30 | # but the output must be the word first 31 | # 32 | # Words should be inserted by `add()`, 33 | # otherwise the order will be broken 34 | self._word_bisect: list[tuple[float, str]] = [] 35 | 36 | # also prepare a dictionary for direct access to word 37 | self._word_dictionary: dict[str, float] = {} 38 | 39 | def add(self, word: str, weight: float) -> None: 40 | """ 41 | Add a word to the data 42 | 43 | :param str word: The word 44 | :param float weight: The weight of the word 45 | :rtype: None 46 | """ 47 | 48 | # negate the weight to get the descending order 49 | bisect.insort(self._word_bisect, (-weight, word)) 50 | 51 | self._word_dictionary[word] = weight 52 | 53 | def add_multiple(self, word_weights: Iterable[tuple[str, float]]) -> None: 54 | """ 55 | Add multiple words to the data 56 | 57 | :param Iterable[Tuple[str, float]] word_weight: The words and their weights 58 | :rtype: None 59 | """ 60 | 61 | for word, weight in word_weights: 62 | self.add(word, weight) 63 | 64 | def get_ranking(self, start: int, end: int) -> list[tuple[str, float]]: 65 | """ 66 | Get the ranking of the words 67 | 68 | This is simply a slice of the bisect_list. 69 | 70 | :param int start: Start of the ranking. 71 | :param int end: End of the ranking. This index will not included. (such as list slice) 72 | If -1, to the last. If exceeds the length, to the last. 73 | :return: The ranking of the words 74 | :rtype: List[Tuple(str, float)] 75 | """ 76 | 77 | # weight was negated 78 | # so negate it back 79 | if end == -1: 80 | return [(tup[1], -tup[0]) for tup in self._word_bisect[start:]] 81 | else: 82 | end = min(end, len(self._word_bisect)) 83 | return [(tup[1], -tup[0]) for tup in self._word_bisect[start:end]] 84 | 85 | def get_weight(self, word: str) -> float: 86 | """ 87 | Get the weight of the word 88 | 89 | :param str word: The word 90 | :return: The weight of the word 91 | :rtype: float 92 | """ 93 | return self._word_dictionary[word] 94 | 95 | def convert_from_dict(word_weights: dict[str, float]) -> WordVector: 96 | """ 97 | Convert from a dictionary of word and weight to WordVector instance. 98 | 99 | This is static conversion method. 100 | 101 | :param Dict[str, float] word_weights: The words and their weights 102 | :return: The WordVector instance 103 | :rtype: WordVector 104 | """ 105 | 106 | instance = WordVector() 107 | 108 | for word, weight in word_weights.items(): 109 | instance.add(word, weight) 110 | 111 | return instance 112 | 113 | 114 | class TimeFrame: 115 | """ 116 | A single time frame of word vector 117 | """ 118 | 119 | def __init__(self, time_name: str, word_vector: WordVector) -> None: 120 | """ 121 | Prepare the time frame 122 | 123 | :param str time_name: Name of the time 124 | :param WordVector word_vector: Word vector 125 | """ 126 | 127 | self.time_name: str = str(time_name) # ensure time_name is string 128 | self.word_vector: WordVector = word_vector 129 | 130 | def convert_from_dict( 131 | time_name: str, word_weights: dict[str, float] 132 | ) -> TimeFrame: 133 | """ 134 | Convert from a dictionary of word and weight to TimeFrame instance. 135 | 136 | This is static conversion method. 137 | 138 | :param str time_name: Name of the time 139 | :param Dict[str, float] word_weights: The words and their weights 140 | :return: The TimeFrame instance 141 | :rtype: TimeFrame 142 | """ 143 | 144 | word_vector = WordVector.convert_from_dict(word_weights) 145 | 146 | return TimeFrame(time_name, word_vector) 147 | 148 | def convert_from_tup_dict(data: Iterable[str, dict[str, float]]): 149 | """ 150 | Convert from a dictionary of word and weight to TimeFrame instance. 151 | 152 | This is static conversion method. 153 | 154 | :param Iterable[str, Dict[str, float]] data: The words and their weights 155 | :return: The TimeFrame instance 156 | :rtype: TimeFrame 157 | """ 158 | 159 | return TimeFrame.convert_from_dict(data[0], data[1]) 160 | 161 | 162 | class TimelapseWordVector: 163 | """ 164 | Timelapse data of word vectors. 165 | 166 | The data structure is a list of TimeFrame 167 | """ 168 | 169 | def __init__(self) -> None: 170 | """ 171 | Prepare empty data 172 | """ 173 | 174 | # main data 175 | self.timeframes: list[TimeFrame] = [] 176 | 177 | def __getitem__( 178 | self, index: int | list[int] 179 | ) -> TimeFrame | list[TimeFrame]: 180 | """ 181 | Returns the item at the given index 182 | 183 | :param int|list[int] index: The index or slice 184 | :return: The timeframe at the given index 185 | :rtype: TimeFrame|List[TimeFrame] 186 | """ 187 | return self.timeframes[index] 188 | 189 | def __len__(self) -> int: 190 | """ 191 | Returns the length of the timelapse data 192 | 193 | :return: The length of the timelapse data 194 | :rtype: int 195 | """ 196 | return len(self.timeframes) 197 | 198 | def add_time_frame(self, timeframe: TimeFrame) -> None: 199 | """ 200 | Add a time frame to the timelapse data 201 | 202 | :param str time_name: Name of the time 203 | :param Dict[str, float] word_vector: Word vector 204 | :rtype: None 205 | """ 206 | 207 | self.timeframes.append(timeframe) 208 | 209 | def convert_from_dicts_list( 210 | data: Iterable[Iterable[str, dict[str, float]]] 211 | ) -> TimelapseWordVector: 212 | """ 213 | Convert from a list of dictionary of word and weight to TimelapseWordVector instance. 214 | 215 | This is static conversion method. 216 | 217 | :param Iterable[Iterable[str, Dict[str, float]]] data: list[(time_name, Dict[word, weight])] 218 | :return: The TimelapseWordVector instance 219 | :rtype: TimelapseWordVector 220 | """ 221 | 222 | instance = TimelapseWordVector() 223 | 224 | for word_weights in data: 225 | instance.add_time_frame( 226 | TimeFrame.convert_from_tup_dict(word_weights) 227 | ) 228 | 229 | return instance 230 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Utils/Config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Config class for passing parameters through the modules 8 | """ 9 | 10 | from __future__ import annotations 11 | import random 12 | from typing import Literal 13 | from AnimatedWordCloud.Utils.Consts import ( 14 | DEFAULT_ENG_FONT_PATH, 15 | DEFAULT_OUTPUT_PATH, 16 | ) 17 | 18 | 19 | class Config: 20 | """ 21 | Config class for AnimatedWordCloud 22 | 23 | :param str font_path: Path to the font file. 24 | If None, default English font will be used. 25 | :param str output_path: Path to the output directory. 26 | If None, default output directory in this library will be used. 27 | Warning: This directory won't be deleted automatically. 28 | So, becareful of your storage. 29 | :param int max_words: Maximum number of words shown in each frame. 30 | :param int max_font_size: Maximum font size of the word. 31 | :param int min_font_size: Minimum font size of the word. 32 | :param int image_width: Width of the image. 33 | :param int image_height: Height of the image. 34 | :param str background_color: Background color of the image, default is "white". 35 | :param str color_map: Colormap to be used for the image, default is "magma". 36 | :param str allocation_stategy: Strategy to allocate words. 37 | There are "magnetic" only for now. 38 | :param int image_division: The number of division of the image. 39 | This is available for magnetic strategy only. 40 | :param float movement_reluctance: Reluctance of the movement of the word. If higher, the word tends to stay near to the previous position. 41 | :param str verbosity: Verbosity of the log. 42 | "silent" for no log, "minor" for only important logs, "debug" for all logs. 43 | :param str transition_symbol: Symbol to be used for showing transition. 44 | It will be shown as "(former) [transition_symbol] (latter)". 45 | :param str starting_time_stamp: Time stamp of the starting time. (Before the first time stamp in the input data) 46 | :param int duration_per_interpolation_frame: Duration of each interpolation frame in milliseconds. 47 | :param int duration_per_static_frame: Duration of each static frame in milliseconds. 48 | :param int n_frames_for_interpolation: Number of frames in the animation. 49 | :param str interpolation_method: Method to interpolate the frames. 50 | There are "linear" only for now. 51 | :param bool drawing_time_stamp: Whether to draw time stamp on the image. 52 | :param str time_stamp_color: Color of the time stamp. 53 | :param int time_stamp_font_size: Font size of the time stamp. 54 | If None(default), it will be set to 75% of max_font_size 55 | :param tuple[int, int] time_stamp_position: Position of the time stamp. 56 | If None(default), it will be set to (image_width*0.75, image_height*0.75) which is right bottom. 57 | :param str intermediate_frames_id: Static images of each frame of itermediate product will be saved as "{intermediate_frames_id}_{frame_number}.png". 58 | If None(default), this will be set randomly. 59 | """ 60 | 61 | def __init__( 62 | self, 63 | font_path: str = None, 64 | output_path: str = None, 65 | max_words: int = 100, 66 | max_font_size: int = 50, 67 | min_font_size: int = 10, 68 | image_width=800, 69 | image_height=600, 70 | background_color: str = "white", 71 | color_map: str = "Dark2", 72 | allocation_strategy: Literal["magnetic"] = "magnetic", 73 | image_division: int = 300, 74 | movement_reluctance: float = 0.05, 75 | verbosity: Literal["silent", "minor", "debug"] = "silent", 76 | transition_symbol: str = " to ", 77 | starting_time_stamp: str = " ", 78 | duration_per_interpolation_frame: int = 50, 79 | duration_per_static_frame: int = 700, 80 | n_frames_for_interpolation: int = 20, 81 | interpolation_method: Literal["linear"] = "linear", 82 | drawing_time_stamp: bool = True, 83 | time_stamp_color: str = "black", 84 | time_stamp_font_size: int = None, 85 | time_stamp_position: tuple[int, int] = None, 86 | intermediate_frames_id: str = None, 87 | ) -> None: 88 | # explanation written above 89 | 90 | # handle nones 91 | if font_path is None: 92 | font_path = DEFAULT_ENG_FONT_PATH 93 | if output_path is None: 94 | output_path = DEFAULT_OUTPUT_PATH 95 | self.font_path = font_path 96 | self.output_path = output_path 97 | self.max_words = max_words 98 | self.max_font_size = max_font_size 99 | self.min_font_size = min_font_size 100 | self.image_width = image_width 101 | self.image_height = image_height 102 | self.background_color = background_color 103 | self.color_map = color_map 104 | self.allocation_strategy = allocation_strategy 105 | self.image_division = image_division 106 | self.movement_reluctance = movement_reluctance 107 | self.verbosity = verbosity 108 | self.transition_symbol = transition_symbol 109 | self.starting_time_stamp = starting_time_stamp 110 | self.duration_per_interpolation_frame = ( 111 | duration_per_interpolation_frame 112 | ) 113 | self.duration_per_static_frame = duration_per_static_frame 114 | self.n_frames_for_interpolation = n_frames_for_interpolation 115 | self.interpolation_method = interpolation_method 116 | self.drawing_time_stamp = drawing_time_stamp 117 | self.time_stamp_color = time_stamp_color 118 | 119 | self.time_stamp_font_size = self._compute_time_stamp_font_size( 120 | time_stamp_font_size 121 | ) 122 | 123 | self.time_stamp_position = self._compute_time_stamp_position( 124 | time_stamp_position 125 | ) 126 | 127 | self.intermediate_frames_id = self._compute_intermediate_frames_id( 128 | intermediate_frames_id 129 | ) 130 | 131 | def _compute_time_stamp_font_size( 132 | self, time_stamp_font_size: int | None 133 | ) -> int: 134 | """ 135 | Compute time_stamp_font_size from constructor argument. 136 | :param int time_stamp_font_size: Font size of the time stamp by the argument. 137 | :return: Font size of the time stamp. 138 | """ 139 | if time_stamp_font_size is None: 140 | return int(self.max_font_size * 0.75) 141 | else: 142 | return time_stamp_font_size 143 | 144 | def _compute_time_stamp_position( 145 | self, time_stamp_position: tuple[int, int] | None 146 | ) -> tuple[int, int]: 147 | """ 148 | Compute time_stamp_position from constructor argument. 149 | :param tuple[int, int] time_stamp_position: Position of the time stamp by the argument. 150 | :return: Position of the time stamp. 151 | """ 152 | if time_stamp_position is None: 153 | return ( 154 | self.image_width * 0.75, 155 | self.image_height * 0.75, 156 | ) # right bottom 157 | else: 158 | return time_stamp_position 159 | 160 | def _compute_intermediate_frames_id( 161 | self, intermediate_frames_id: str | None 162 | ) -> str: 163 | """ 164 | returns intermediate_frames_id given from constructor 165 | set random value if intermediate_frames_id is None 166 | :param str|None intermediate_frames_id: intermediate_frames_id given 167 | :return: intermediate_frames_id computed 168 | :rtype: str 169 | """ 170 | 171 | if intermediate_frames_id is None: 172 | # set randomly 173 | return str(random.randint(0, 1000000000)) 174 | else: 175 | return intermediate_frames_id 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AnimatedWordCloud ver 1.0.9 2 | 3 | 4 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/20a71da0d9d841a2af236f6362a08ae7)](https://app.codacy.com/gh/konbraphat51/AnimatedWordCloud/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) 5 | [![unit-test](https://github.com/konbraphat51/AnimatedWordCloud/actions/workflows/python-tester.yml/badge.svg?branch=main)](https://github.com/konbraphat51/AnimatedWordCloud/actions/workflows/python-tester.yml)[![codecov](https://codecov.io/gh/konbraphat51/AnimatedWordCloud/graph/badge.svg?token=4OOX0GSJDJ)](https://codecov.io/gh/konbraphat51/AnimatedWordCloud) 6 | 7 | AnimatedWordCloud animates the timelapse of your words vector. 8 | 9 | ## Examples! 10 | 11 | Using [Elon Musk's tweets](https://data.world/adamhelsinger/elon-musk-tweets-until-4-6-17). 12 | (C) Elon Musk 13 | 14 | ![output_elon](https://github.com/konbraphat51/AnimatedWordCloud/assets/101827492/89052c20-b228-42d8-921e-ebae9f7e30a0) 15 | 16 | [Procedure Notebook](https://github.com/konbraphat51/AnimatedWordCloudExampleElon) 17 | 18 | ## How to use? 19 | 20 | ### Requirements 21 | 22 | Python (3.8 <= version <= 3.12) 23 | 24 | ### install 25 | 26 | **BE CAREFUL of the name** 27 | 28 | ❌AnimatedWordCloud 29 | ✅AnimatedWordCloudTimelapse 30 | 31 | ``` 32 | pip install AnimatedWordCloudTimelapse 33 | ``` 34 | 35 | ### coding 36 | 37 | See [Example Notebook](https://github.com/konbraphat51/AnimatedWordCloudExampleElon) for details 38 | 39 | #### Using default configuration 40 | 41 | ```python 42 | from AnimatedWordCloud import animate 43 | 44 | # data must be list[("time name", dict[str, float])] 45 | timelapse_wordvector = [ 46 | ( 47 | "time_0", #time stamp 48 | { 49 | "hanshin":0.334, #word -> weight 50 | "chiba":0.226 51 | } 52 | ), 53 | ( 54 | "time_1", 55 | { 56 | "hanshin":0.874, 57 | "fujinami":0.609 58 | } 59 | ), 60 | ( 61 | "time_2", 62 | { 63 | "fujinami":0.9, 64 | "major":0.4 65 | } 66 | ) 67 | ] 68 | 69 | # animate! 70 | # the animation gif path is in this variable! 71 | path = animate(timelapse_wordvector) 72 | ``` 73 | 74 | #### Editing configuration 75 | 76 | ```python 77 | from AnimatedWordCloud import animate, Config 78 | 79 | config = Config( 80 | what_you_want_to_edit = editing_value 81 | ) 82 | 83 | timelapse = # adding time lapse data 84 | 85 | #give the config to second parameter 86 | animate(timelapse, config) 87 | ``` 88 | 89 | ##### Parameters of `Config` 90 | 91 | All has default value, so just edit what you need 92 | 93 | | parameter name | type | meaning | 94 | | -------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 95 | | font_path | str | Path to the font file. | 96 | | output_path | str | Parh of the output directory | 97 | | max_words | int | max number of the words in the screen | 98 | | max_font_size | int | Maximum font size of the word | 99 | | min_font_size | int | Minimum font size of the word | 100 | | image_width | int | Width of the image | 101 | | image_height | int | Height of the image | 102 | | background_color | str | Background color.
This is based on [Pillow.Image.new()](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.new) | 103 | | color_map | str | color map used for coloring words
This is based on [matplotlib colormap](https://matplotlib.org/stable/users/explain/colors/colormaps.html) | 104 | | allocation_strategy | str(literal) | allocation algorithm method. This will change the allocation of the words in the output.
There is "magnetic" now. | 105 | | image_division | int | precision of allocation calculation. Higher the preciser, but calculation slower | 106 | | movement_reluctance | float | Reluctance of the movement of the word. If higher, the word tends to stay near to the previous position. | 107 | | verbosity | str(literal) | logging.
silent: nothing
minor: bars to know the progress
debug: all progress. noisy | 108 | | transition_symbol | str | written in the image | 109 | | starting_time_stamp | str | time stamp of the first frame (before the first time stamp in the input timelapse data) | 110 | | duration_per_interpolation_frame | int | milliseconds per interpolation frame | 111 | | duration_per_static_frame | int | milliseconds per staic (frame correspond to timestamp of wordvector) frame | 112 | | n_frames_for_interpolation | int | how many frames will be generated for interpolation between each frames | 113 | | interpolation_method | str(literal) | The method of making movement
There is "linear" now | 114 | | drawing_time_stamp | bool | Whether to draw time stamp on the image | 115 | | time_stamp_color | str | Color of the time stamp. This is based on [`Pillow ImageColor`](https://pillow.readthedocs.io/en/stable/reference/ImageColor.html#color-names) | 116 | | time_stamp_font_size | int | Font size of the time stamp.
If None(default), it will be set to 75% of max_font_size | 117 | | time_stamp_position | tuple[int, int] | Position of the time stamp.
If None(default), it will be set to (image_width*0.75, image_height*0.75) which is right bottom. | 118 | | intermediate_frames_id | str | Static images of each frame of itermediate product will be saved as "{intermediate*frames_id}*{frame_number}.png".
If None(default), this will be set randomly. | 119 | 120 | ## Want to contribute? 121 | 122 | Look at [CONTRIBUTING.md](CONTRIBUTING.md) first. 123 | 124 | ### Maintainers 125 | 126 | - [Konbraphat51](https://github.com/konbraphat51): Head Author 127 | [![Konbraphat51 icon](https://github.com/konbraphat51.png)](https://github.com/konbraphat51) 128 | 129 | - [SuperHotDogCat](https://github.com/SuperHotDogCat): Author 130 | [![SuperHotDogCat](https://github.com/SuperHotDogCat.png)](https://github.com/SuperHotDogCat) 131 | 132 | ## Want to support? 133 | 134 | **⭐Give this project a star⭐** 135 | This is our first OSS project, ⭐**star**⭐ would make us very happy⭐⭐⭐ 136 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AllocationCalculator/AnimatetdAllocationCalculator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Calculate interpolated allocations between timestamps 8 | """ 9 | 10 | from __future__ import annotations 11 | import numpy as np 12 | from AnimatedWordCloud.Utils import ( 13 | AllocationTimelapse, 14 | AllocationInFrame, 15 | Config, 16 | ) 17 | 18 | 19 | def animated_allocate( 20 | allocation_timelapse: AllocationTimelapse, config: Config 21 | ) -> AllocationTimelapse: 22 | """ 23 | :param AllocationTimelapse allocation_timelapse: static allocations already calculated (AllocationTimelapse) 24 | :param Config config: 25 | :return: AllocationTimelapse which interpolation allocation for animation inserted (AllocationTimelapse) 26 | :rtype: AllocationTimelapse 27 | """ 28 | 29 | # final output 30 | timelapse_output: AllocationTimelapse = AllocationTimelapse() 31 | 32 | # Branching by interpolation_method 33 | n_timestamps = len( 34 | allocation_timelapse.timelapse 35 | ) # get the number of timestamps 36 | 37 | # Interpolate between timestamps. the positions of words are changed by linear. 38 | for index in range(n_timestamps - 1): 39 | # get static frame data 40 | from_allocation_frame = allocation_timelapse.get_frame(index) 41 | to_allocation_frame = allocation_timelapse.get_frame(index + 1) 42 | time_name_from = allocation_timelapse.timelapse[index][0] 43 | time_name_to = allocation_timelapse.timelapse[index + 1][0] 44 | 45 | # interpolate between two frames 46 | interpolated_frames = _get_interpolated_frames( 47 | from_allocation_frame, 48 | to_allocation_frame, 49 | config, 50 | ) 51 | 52 | # add static frame first 53 | timelapse_output.add(time_name_from, from_allocation_frame) 54 | 55 | # add interpolated frames 56 | time_name = time_name_from + config.transition_symbol + time_name_to 57 | for interpolated_frame in interpolated_frames: 58 | timelapse_output.add(time_name, interpolated_frame) 59 | 60 | # add last static frame 61 | timelapse_output.add(time_name_to, to_allocation_frame) 62 | 63 | return timelapse_output 64 | 65 | 66 | def _get_setdiff( 67 | from_allocation_frame: AllocationInFrame, 68 | to_allocation_frame: AllocationInFrame, 69 | ) -> tuple[list[str], list[str]]: 70 | """ 71 | Extract the keys to be added 72 | 73 | :param AllocationInFrame from_allocation_frame, to_allocation_frame: start frame and end frame 74 | :return: the keys to be added 75 | """ 76 | from_words: list[str] = list(from_allocation_frame.words.keys()) 77 | to_words: list[str] = list(to_allocation_frame.words.keys()) 78 | 79 | # np.setdiff1(x, y)'s returns an array x excluding the elements contained in array y. 80 | from_words_to_be_added_key = np.setdiff1d(to_words, from_words) 81 | to_words_to_be_added_key = np.setdiff1d(from_words, to_words) 82 | return from_words_to_be_added_key, to_words_to_be_added_key 83 | 84 | 85 | def _add_key_in_allocation_frame( 86 | from_allocation_frame: AllocationInFrame, 87 | to_allocation_frame: AllocationInFrame, 88 | from_words_to_be_added_key: list[str], 89 | to_words_to_be_added_key: list[str], 90 | ) -> tuple[AllocationInFrame, AllocationInFrame]: 91 | """ 92 | :param AllocationInFrame from_allocation_frame, to_allocation_frame: start frame and end frame 93 | :param list[str] from_words_to_be_added_key, to_words_to_be_added_key: the keys to be added 94 | :return: AllocationInFrame from_allocation_frame, to_allocation_frame: start and end frame added to the necessary keys 95 | :rtype: tuple[AllocationInFrame, AllocationInFrame] 96 | """ 97 | for to_be_added_key in from_words_to_be_added_key: 98 | # add key in from_allocation_frame 99 | (font_size, left_top) = to_allocation_frame[to_be_added_key] 100 | from_allocation_frame.add(to_be_added_key, font_size, left_top) 101 | 102 | for to_be_added_key in to_words_to_be_added_key: 103 | # add key in to_allocation_frame 104 | (font_size, left_top) = from_allocation_frame[to_be_added_key] 105 | to_allocation_frame.add(to_be_added_key, font_size, left_top) 106 | 107 | return from_allocation_frame, to_allocation_frame 108 | 109 | 110 | def _calc_frame_value( 111 | from_value: float, to_value: float, index: int, config: Config 112 | ) -> float: 113 | """ 114 | :param float from_value, to_value: font_size, x_position, or y_position 115 | :param int index: interpolation frame index 116 | :param Config config used for n_frames_for_interpolation and interpolation_method 117 | :return: calculated interpolation's value 118 | :rtype: float 119 | 120 | Linear only for now 121 | Add non-Linear interpolation processes in the future. 122 | """ 123 | if config.interpolation_method == "linear": 124 | value = _calc_linear(from_value, to_value, index, config) 125 | else: 126 | raise NotImplementedError() 127 | return value 128 | 129 | 130 | def _calc_linear( 131 | from_value: float, to_value: float, index: int, config: Config 132 | ): 133 | """ 134 | :param float from_value, to_value: font_size, x_position, or y_position 135 | :param int index: interpolation frame index 136 | :param Config config used for n_frames_for_interpolation 137 | :return: calculated linear interpolation's value 138 | :rtype: float 139 | 140 | Linear only for now 141 | Add non-Linear interpolation processes in the future. 142 | """ 143 | value = from_value + index / (config.n_frames_for_interpolation + 1) * ( 144 | to_value - from_value 145 | ) 146 | return value 147 | 148 | 149 | def _calc_added_frame( 150 | from_allocation_frame: AllocationInFrame, 151 | to_allocation_frame: AllocationInFrame, 152 | key: str, 153 | index: int, 154 | config: Config, 155 | ) -> tuple[float, float, float]: 156 | """ 157 | :param float from_allocation_frame, to_allocation_frame: start frame and end frame 158 | :param str key: a focused word. It's interpolation font size and positions are calculated. 159 | :param int index: interpolation frame index 160 | :param Config config: used for the argument of _calc_frame_value 161 | :return: calculated interpolation's values (frame_font_size, frame_x_pos, frame_y_pos) 162 | :rtype: tuple[float, float, float] 163 | """ 164 | from_font_size = from_allocation_frame[key][0] 165 | to_font_size = to_allocation_frame[key][0] 166 | from_x_pos = from_allocation_frame[key][1][0] 167 | to_x_pos = to_allocation_frame[key][1][0] 168 | from_y_pos = from_allocation_frame[key][1][1] 169 | to_y_pos = to_allocation_frame[key][1][1] 170 | frame_font_size = _calc_frame_value( 171 | from_font_size, to_font_size, index, config 172 | ) 173 | frame_x_pos = _calc_frame_value(from_x_pos, to_x_pos, index, config) 174 | frame_y_pos = _calc_frame_value(from_y_pos, to_y_pos, index, config) 175 | return frame_font_size, frame_x_pos, frame_y_pos 176 | 177 | 178 | def _get_interpolated_frames( 179 | from_allocation_frame: AllocationInFrame, 180 | to_allocation_frame: AllocationInFrame, 181 | config: Config, 182 | ) -> list[AllocationInFrame]: 183 | """ 184 | get_interpolated_frames Algorithm: 185 | 1. Align word counts in from_allocation_frame and to_allocation_frame 186 | 2. Calculate interpolated frames using each of these methods for example, linear 187 | 188 | :param AllocationInFrame from_allocation_frame, to_allocation_frame: start frame and end frame 189 | :param Config config: 190 | :return: interpolated frames 191 | :rtype: list[AllocationInFrame] 192 | """ 193 | # Linear only for now 194 | n_frames_for_interpolation = config.n_frames_for_interpolation 195 | from_words_to_be_added_key, to_words_to_be_added_key = _get_setdiff( 196 | from_allocation_frame, to_allocation_frame 197 | ) 198 | from_allocation_frame, to_allocation_frame = _add_key_in_allocation_frame( 199 | from_allocation_frame, 200 | to_allocation_frame, 201 | from_words_to_be_added_key, 202 | to_words_to_be_added_key, 203 | ) 204 | to_be_added_frames: list[dict[str, tuple[float, tuple[float, float]]]] = [ 205 | {} for _ in range(n_frames_for_interpolation) 206 | ] # not [{}] * n_frames_for_interpolation 207 | # dict[str, tuple[float, tuple[float, float]]]: word -> (font size, left-top position) 208 | all_keys = list(from_allocation_frame.words.keys()) 209 | for key in all_keys: 210 | for index in range(1, n_frames_for_interpolation + 1): 211 | # from_value + index / (n_frames_for_interpolation + 1) * (to_value - from_value) 212 | # index: 1, 2, ..., n_frames_for_interpolation, so 1 - indexed 213 | frame_font_size, frame_x_pos, frame_y_pos = _calc_added_frame( 214 | from_allocation_frame, to_allocation_frame, key, index, config 215 | ) 216 | to_be_added_frames[index - 1][key] = ( 217 | frame_font_size, 218 | (frame_x_pos, frame_y_pos), 219 | ) 220 | 221 | # Generate interpolated_frames as AllocationInFrame 222 | output = [] 223 | for index, to_be_added_frame in enumerate(to_be_added_frames): 224 | to_be_added_allocation_frame = AllocationInFrame( 225 | from_static_allocation=False 226 | ) 227 | to_be_added_allocation_frame.words = to_be_added_frame 228 | output.append(to_be_added_allocation_frame) 229 | 230 | return output 231 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationStrategies/MagneticAllocation/MagnetOuterFrontier.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | Frontier of magnet at the center 8 | 9 | Used by MagneticAllocation 10 | """ 11 | 12 | 13 | from __future__ import annotations 14 | from typing import Iterable 15 | from bisect import bisect 16 | from AnimatedWordCloud.Utils import ( 17 | Vector, 18 | Rect, 19 | is_point_hitting_rects, 20 | is_point_hitting_rect, 21 | ) 22 | 23 | TO_RIGHT = Vector(0, 0) 24 | TO_LEFT = Vector(0, 0) 25 | TO_UP = Vector(0, 0) 26 | TO_DOWN = Vector(0, 0) 27 | 28 | 29 | class MagnetOuterFrontier: 30 | """ 31 | Outer frontier of the magnet at the center. 32 | This is used to find the next position of the next word. 33 | 34 | This described by launching a lazer from the boarder of the image; 35 | from up, down, left, and right. 36 | And find the first point that is not overlapped with the magnet. 37 | """ 38 | 39 | def __init__(self) -> None: 40 | """ 41 | Make empty data 42 | """ 43 | 44 | self.from_up: list[tuple[int, int]] = [] 45 | self.from_down: list[tuple[int, int]] = [] 46 | self.from_left: list[tuple[int, int]] = [] 47 | self.from_right: list[tuple[int, int]] = [] 48 | 49 | 50 | def get_magnet_outer_frontier( 51 | rects: Iterable[Rect], 52 | image_width: int, 53 | image_height: int, 54 | interval_x: float, 55 | interval_y: float, 56 | rect_added: Rect, 57 | frontier_former: MagnetOuterFrontier, 58 | ) -> MagnetOuterFrontier: 59 | """ 60 | Find the outer frontier of the magnet at the center 61 | 62 | :param Iterable[Rect] rects: Rectangles that are currently putted in the magnet 63 | :param int image_width: Width of the image 64 | :param int image_height: Height of the image 65 | :param int interval_x: interval of the precision; x 66 | :param int interval_y: interval of the precision; y 67 | :param Rect rect_added: Rectangle that is added at the last step. If specified with frontier_former, the calculation is faster. 68 | :param MagnetOuterFrontier frontier_former: Former frontier. If specified with rect_added, the calculation is faster. 69 | :return: Outer frontier of the magnet at the center 70 | :rtype: MagnetOuterFrontier 71 | """ 72 | 73 | _initialize_directions(interval_x, interval_y) 74 | 75 | magnet_outer_frontier = MagnetOuterFrontier() 76 | 77 | # prepare for iteration 78 | # need to shift 1 for the collision detection 79 | launcher_start_positions = [ 80 | Vector(1, 1), # from up 81 | Vector(1, image_height - 1), # from down 82 | Vector(1, 1), # from left 83 | Vector(image_width - 1, 1), # from right 84 | ] 85 | launcher_directions = [ 86 | TO_RIGHT, # from up 87 | TO_RIGHT, # from down 88 | TO_DOWN, # from left 89 | TO_DOWN, # from right 90 | ] 91 | detection_ray_directions = [ 92 | TO_DOWN, # from up 93 | TO_UP, # from down 94 | TO_RIGHT, # from left 95 | TO_LEFT, # from right 96 | ] 97 | former_frontiers_by_side = [ 98 | frontier_former.from_up, # from up 99 | frontier_former.from_down, # from down 100 | frontier_former.from_left, # from left 101 | frontier_former.from_right, # from right 102 | ] 103 | 104 | # detect from 4 sides 105 | corresponding_frontiers = [] 106 | for cnt in range(4): 107 | # detect 108 | detected_points = _detect_frontier_linealy( 109 | launcher_start_positions[cnt], 110 | launcher_directions[cnt], 111 | detection_ray_directions[cnt], 112 | rects, 113 | image_width, 114 | image_height, 115 | rect_added, 116 | former_frontiers_by_side[cnt], 117 | ) 118 | 119 | # update 120 | corresponding_frontiers.append(detected_points) 121 | 122 | # update magnet_outer_frontier 123 | magnet_outer_frontier.from_up = corresponding_frontiers[0] 124 | magnet_outer_frontier.from_down = corresponding_frontiers[1] 125 | magnet_outer_frontier.from_left = corresponding_frontiers[2] 126 | magnet_outer_frontier.from_right = corresponding_frontiers[3] 127 | 128 | return magnet_outer_frontier 129 | 130 | 131 | def _detect_frontier_linealy( 132 | launcher_point_start: Vector, 133 | launcher_direction: Vector, 134 | detection_ray_direction: Vector, 135 | rects: Iterable[Rect], 136 | image_width: int, 137 | image_height: int, 138 | rect_added: Rect, 139 | frontier_side: list[tuple[int, int]], 140 | ) -> list[tuple[int, int]]: 141 | """ 142 | Detect the frontier from 1 line. 143 | 144 | This first set a launcher at the starting point. 145 | Then launch a detection ray from the launcher, 146 | and move the detection ray until some rect hits. 147 | Then move the launcher and the detection ray in the same direction, 148 | again and again, until the launcher is out of the image. 149 | 150 | :param Vector launcher_point_start: 151 | Starting point of the detection ray launching position 152 | :param Vector launcher_points_direction: 153 | Direction vector of the launching position moves 154 | :param Vector detection_ray_direction: 155 | Direction vector of the detection ray moves 156 | :param Iterable[Rect] rects: 157 | Word's Rectangles that are currently putted in the magnet 158 | :param int image_width: Width of the image 159 | :param int image_height: Height of the image 160 | :param Rect rect_added: Rectangle that is added at the last step. 161 | :param list[tuple[int, int]] frontier_former_side: List of points of a side if former frontier. 162 | :return: List of points that are detected 163 | :rtype: list[tuple[int, int]] 164 | """ 165 | # a clone made 166 | frontier_side = _sort_by_direction(frontier_side, detection_ray_direction) 167 | 168 | # true while the launcher is in the area hitting the rect_added 169 | hitting = False 170 | 171 | image_size = (image_width, image_height) 172 | launcher_position = launcher_point_start.clone() 173 | 174 | # while lancher is inside the image... 175 | while is_point_hitting_rect(launcher_position, Rect((0, 0), image_size)): 176 | # if ray will hit the new rect... 177 | if _will_hit_rect_added( 178 | launcher_position, launcher_direction, rect_added 179 | ): 180 | # handle flag 181 | hitting = True 182 | 183 | # launch a ray 184 | result_ray_launched = _launch_ray( 185 | launcher_position, 186 | detection_ray_direction, 187 | rects, 188 | Rect((0, 0), image_size), 189 | ) 190 | 191 | # update frontier 192 | _remember_ray_result( 193 | result_ray_launched, frontier_side, launcher_direction 194 | ) 195 | 196 | # if the ray launcher escapes from the new rect hitting area... 197 | elif hitting: 198 | # ...stop 199 | break 200 | 201 | # move launcher 202 | launcher_position += launcher_direction 203 | 204 | return frontier_side 205 | 206 | 207 | def _remember_ray_result( 208 | result_ray_launched: tuple[Vector, Rect] | None, 209 | detected_points: list[tuple[int, int]], 210 | launcher_direction: Vector, 211 | ) -> None: 212 | """ 213 | remember the result of the ray launched. 214 | 215 | :param tuple[Vector, Rect]|None result_ray_launched: Result of the ray launched 216 | :param list[tuple[int, int]] detected_points: List of points that are detected. This will be modified. 217 | :param Vector launcher_direction: Direction vector of the launcher 218 | :rtype: None 219 | """ 220 | 221 | if result_ray_launched is None: 222 | return 223 | 224 | # get the result 225 | detection_ray_position, _ = result_ray_launched 226 | 227 | # overwrite the old list 228 | _add_newly_found_point( 229 | detection_ray_position, detected_points, launcher_direction 230 | ) 231 | 232 | 233 | def _launch_ray( 234 | launching_position: Vector, 235 | detection_ray_direction: Vector, 236 | rects: Iterable[Rect], 237 | image_rect: Rect, 238 | ) -> tuple[Vector, Rect] | None: 239 | """ 240 | Launch a detection ray from the launching position, and find the first point hits. 241 | 242 | This finds the frontier on the ray line. Intended to be used by `_detect_frontier_linealy()` 243 | 244 | :param Vector launching_position: Starting position of the ray 245 | :param Vector detection_ray_direction: Direction vector of the detection ray moves 246 | :param Iterable[Rect] rects: Rectangles that are currently putted in the magnet 247 | :param Rect image_rect: Rectangle of the image 248 | :return: If hitted -> (Position of the first point hits, Rectangle that is hitting); if not hitted -> None 249 | :rtype: Tuple[Vector, Rect]|None 250 | """ 251 | # starting position of the ray 252 | # clone to avoid modifying the original vector 253 | detection_ray_position = launching_position.clone() 254 | 255 | # while detection ray is inside the image... 256 | while is_point_hitting_rect(detection_ray_position, image_rect): 257 | # check hit 258 | flag_hitted, hitted_rect = is_point_hitting_rects( 259 | detection_ray_position, rects 260 | ) 261 | 262 | if flag_hitted: 263 | return (detection_ray_position, hitted_rect) 264 | 265 | # move detection ray 266 | detection_ray_position += detection_ray_direction 267 | 268 | return None 269 | 270 | 271 | def _sort_by_direction( 272 | points: list[tuple[int, int]], 273 | direction: Vector, 274 | ) -> list[tuple[int, int]]: 275 | """ 276 | Sort the points by the direction. 277 | 278 | :param list[tuple[int, int]] points: List of points. This won't be modified. 279 | :param Vector direction: Direction vector. Must be either TO_RIGHT, TO_LEFT, TO_UP, or TO_DOWN. 280 | :return: Sorted list of points 281 | :rtype: list[tuple[int, int]] 282 | """ 283 | # also get components of the target direction for using bisect.bisect 284 | 285 | points = points.copy() 286 | 287 | # if direction is x-axis... 288 | if direction.x == 0: 289 | # sort by x in ascending order 290 | points.sort(key=lambda x: x[0]) 291 | 292 | else: 293 | # sort by y in ascending order 294 | points.sort(key=lambda x: x[1]) 295 | 296 | return points 297 | 298 | 299 | def _initialize_directions(interval_x: float, interval_y: float) -> None: 300 | """ 301 | Initialize the direction vectors. 302 | 303 | :param float interval_x: interval of the precision; x 304 | :param float interval_y: interval of the precision; y 305 | :rtype: None 306 | """ 307 | 308 | global TO_RIGHT, TO_LEFT, TO_UP, TO_DOWN 309 | 310 | TO_RIGHT = Vector(interval_x, 0) 311 | TO_LEFT = Vector(-interval_x, 0) 312 | TO_UP = Vector(0, -interval_y) 313 | TO_DOWN = Vector(0, interval_y) 314 | 315 | 316 | def _will_hit_rect_added( 317 | launcher_position: Vector, 318 | launcher_direction: Vector, 319 | rect_added: Rect, 320 | ) -> bool: 321 | """ 322 | Check if the launcher will hit the rect_added. 323 | 324 | :param Vector launcher_position: Position of the launcher 325 | :param Vector launcher_direction: Direction vector of the launcher 326 | :param Rect rect_added: Rectangle that is added at the last step. 327 | :return: If the launcher will hit the rect_added -> True, else -> False 328 | :rtype: bool 329 | """ 330 | 331 | # if the launcher moving vertically... 332 | if launcher_direction.x == 0: 333 | # ...check y axis 334 | return ( 335 | rect_added.left_top[1] 336 | <= launcher_position.y 337 | <= rect_added.right_bottom[1] 338 | ) 339 | 340 | # if the launcher moving horizontally... 341 | else: 342 | # ...check x axis 343 | return ( 344 | rect_added.left_top[0] 345 | <= launcher_position.x 346 | <= rect_added.right_bottom[0] 347 | ) 348 | 349 | 350 | def _add_newly_found_point( 351 | point_found: tuple[int, int], 352 | frontier_points: list[tuple[int, int]], 353 | launcher_direction: Vector, 354 | ) -> None: 355 | """ 356 | Add the newly found point to the frontier. 357 | 358 | :param tuple[int, int] point_found: Point found 359 | :param list[tuple[int, int]] frontier_points: List of points of the frontier. This will be modified. 360 | :param Vector launcher_direction: Direction vector of the launcher 361 | :rtype: None 362 | """ 363 | 364 | # if the launcher moving vertically... 365 | if launcher_direction.x == 0: 366 | # find by y axis 367 | components = [point[1] for point in frontier_points] 368 | 369 | _add_newly_found_point_with_specified_component( 370 | components, frontier_points, point_found, point_found[1] 371 | ) 372 | 373 | # if the launcher moving horizontally... 374 | else: 375 | # find by x axis 376 | components = [point[0] for point in frontier_points] 377 | 378 | _add_newly_found_point_with_specified_component( 379 | components, frontier_points, point_found, point_found[0] 380 | ) 381 | 382 | 383 | def _add_newly_found_point_with_specified_component( 384 | components: list[int], 385 | frontier_points: list[tuple[int, int]], 386 | point_found: tuple[int, int], 387 | point_component: int, 388 | ) -> None: 389 | """ 390 | Update the frontier with the newly found point by _add_newly_found_point() 391 | 392 | :param list[int] components: List of components of the frontier points 393 | :param list[tuple[int, int]] frontier_points: List of points of the frontier. This will be modified. 394 | :param tuple[int, int] point_found: Point found 395 | :param int point_component: Component of the point found 396 | :rtype: None 397 | """ 398 | index = bisect(components, point_component) 399 | 400 | # if there was a proceeding point... 401 | if (index < len(components)) and (point_component == components[index]): 402 | # ... overwrite 403 | frontier_points[index] = point_found 404 | 405 | # if there was no proceeding point... 406 | else: 407 | # ... newly insert 408 | 409 | # bisect.bisect returns the index of the point that is bigger than the target point 410 | frontier_points.insert(index, point_found) 411 | -------------------------------------------------------------------------------- /AnimatedWordCloud/Animator/AllocationCalculator/StaticAllocationCalculator/StaticAllocationStrategies/MagneticAllocation/MagneticAllocation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2023 AnimatedWordCloud Project 3 | # https://github.com/konbraphat51/AnimatedWordCloud 4 | # 5 | # Licensed under the MIT License. 6 | """ 7 | One strategy to allocate words in static time. 8 | 9 | First, put the largest word in the center. 10 | Regard this word as a magnet. 11 | Then, put the next largest word at empty point, contacting with the magnet at the center. 12 | The point will be evaluated by evaluate_position(), and the most best point will be selected. 13 | Repeating this process, all words will be allocated. 14 | """ 15 | 16 | from __future__ import annotations 17 | import math 18 | from typing import Iterable 19 | from tqdm import tqdm 20 | from AnimatedWordCloud.Utils import ( 21 | is_rect_hitting_rects, 22 | AllocationInFrame, 23 | Vector, 24 | Rect, 25 | Word, 26 | Config, 27 | ) 28 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies.StaticAllocationStrategy import ( 29 | StaticAllocationStrategy, 30 | ) 31 | from AnimatedWordCloud.Animator.AllocationCalculator.StaticAllocationCalculator.StaticAllocationStrategies.MagneticAllocation.MagnetOuterFrontier import ( 32 | MagnetOuterFrontier, 33 | get_magnet_outer_frontier, 34 | ) 35 | 36 | 37 | class MagneticAllocation(StaticAllocationStrategy): 38 | def __init__( 39 | self, 40 | config: Config, 41 | ): 42 | """ 43 | Initialize allocation settings 44 | """ 45 | super().__init__(config) 46 | 47 | self.interval_x = self.config.image_width / self.config.image_division 48 | self.interval_y = self.config.image_height / self.config.image_division 49 | 50 | def allocate( 51 | self, words: Iterable[Word], allocation_before: AllocationInFrame 52 | ) -> AllocationInFrame: 53 | """ 54 | Allocate words in magnetic strategy 55 | 56 | :param Iterable[Word] words: Words to allocate. Order changes the result. 57 | :param AllocationInFrame allocation_before: Allocation data at one static frame before 58 | :return: Allocation data of the frame 59 | :rtype: AllocationInFrame 60 | """ 61 | 62 | self.words = words 63 | self.allocations_before = allocation_before 64 | 65 | # missing word for previous frame 66 | self.add_missing_word_to_previous_frame(allocation_before, words) 67 | 68 | output = AllocationInFrame(from_static_allocation=True) 69 | magnet_outer_frontier = MagnetOuterFrontier() 70 | 71 | # Word rectangles that are currenly putted at the outermost of the magnet 72 | self.rects = set() 73 | 74 | # put the first word at the center 75 | self.center = ( 76 | self.config.image_width / 2, 77 | self.config.image_height / 2, 78 | ) 79 | first_word = self.words[0] 80 | first_word_position = ( 81 | self.center[0] - first_word.text_size[0] / 2, 82 | self.center[1] - first_word.text_size[1] / 2, 83 | ) 84 | 85 | # register the first word 86 | rect_adding = Rect( 87 | first_word_position, 88 | ( 89 | first_word_position[0] + first_word.text_size[0], 90 | first_word_position[1] + first_word.text_size[1], 91 | ), 92 | ) 93 | self.rects.add(rect_adding) 94 | output.add(first_word.text, first_word.font_size, first_word_position) 95 | 96 | # verbose for iteration 97 | if self.config.verbosity == "debug": 98 | print("MagneticAllocation: Start iteration...") 99 | iterator = tqdm(self.words[1:]) 100 | else: 101 | iterator = self.words[1:] 102 | 103 | # from second word 104 | for word in iterator: 105 | # get outer frontier of the magnet 106 | # The position candidates will be selected from this frontier 107 | magnet_outer_frontier = get_magnet_outer_frontier( 108 | self.rects, 109 | self.config.image_width, 110 | self.config.image_height, 111 | self.interval_x, 112 | self.interval_y, 113 | rect_adding, 114 | magnet_outer_frontier, 115 | ) 116 | 117 | # find the best left-top position 118 | position = self._find_best_position( 119 | word, 120 | magnet_outer_frontier, 121 | self.allocations_before[word.text][1], 122 | ) 123 | 124 | # update for next iteration 125 | rect_adding = Rect( 126 | position, 127 | ( 128 | position[0] + word.text_size[0], 129 | position[1] + word.text_size[1], 130 | ), 131 | ) 132 | 133 | # register rect 134 | self.rects.add(rect_adding) 135 | 136 | # register to output 137 | output.add(word.text, word.font_size, position) 138 | 139 | # add missing words for this frame 140 | self.add_missing_word_from_previous_frame(allocation_before, output) 141 | 142 | return output 143 | 144 | def _evaluate_position( 145 | self, position_from: tuple[int, int], position_to: tuple[int, int] 146 | ) -> float: 147 | """ 148 | Evaluate the position the word beginf to put 149 | 150 | :param tuple[int,int] position_from: 151 | Position of the center of the word comming from 152 | :param tuple[int,int] position_to: 153 | Position of the center of the word going to be putted 154 | :param tuple[int,int] center: Position of the center of the magnet 155 | :return: Evaluation value. Smaller is the better 156 | """ 157 | 158 | distance_movement = math.sqrt( 159 | (position_from[0] - position_to[0]) ** 2 160 | + (position_from[1] - position_to[1]) ** 2 161 | ) 162 | 163 | distance_center = math.sqrt( 164 | (position_to[0] - self.center[0]) ** 2 165 | + (position_to[1] - self.center[1]) ** 2 166 | ) 167 | 168 | # the larger, the better; This need manual adjustment 169 | # adjust the coefficient mannually by visual testing 170 | 171 | # log(distance_movement): more important when near, not when far 172 | return ( 173 | -self.config.movement_reluctance 174 | * math.log(distance_movement + 0.01) 175 | - 1.0 * distance_center**2 176 | ) 177 | 178 | def _find_best_position( 179 | self, 180 | word: Word, 181 | magnet_outer_frontier: MagnetOuterFrontier, 182 | position_from: tuple[int, int], 183 | ) -> tuple[int, int]: 184 | """ 185 | Find the best position to put the word 186 | 187 | Find the best position to put the word in the `magnet_outer_frontier`. 188 | The positions will be evaluated by `evaluate_position()`, and the best scored position will be returned. 189 | 190 | :param Word word: Word to put 191 | :param MagnetOuterFrontier magnet_outer_frontier: 192 | Outer frontier of the magnet at the center 193 | :param tuple[int,int] position_from: 194 | Position of the center of the word comming from 195 | :return: Best left-top position to put the word 196 | :rtype: tuple[int,int] 197 | """ 198 | 199 | # + interval for a buffer for the collision detection 200 | # if not, this might get into the word's rects 201 | x_half = word.text_size[0] / 2 + self.interval_x 202 | y_half = word.text_size[1] / 2 + self.interval_y 203 | 204 | # Prepare for iteration 205 | pivots_to_center_list = [ 206 | # from lower 207 | [ 208 | Vector(0, y_half), 209 | ], 210 | # from top 211 | [ 212 | Vector(0, -y_half), 213 | ], 214 | # from left 215 | [ 216 | Vector(-x_half, 0), 217 | ], 218 | # from right 219 | [ 220 | Vector(x_half, 0), 221 | ], 222 | ] 223 | 224 | frontier_sides = [ 225 | magnet_outer_frontier.from_down, 226 | magnet_outer_frontier.from_up, 227 | magnet_outer_frontier.from_left, 228 | magnet_outer_frontier.from_right, 229 | ] 230 | 231 | # get center position candidates 232 | center_position_candidates = self._compute_candidates( 233 | pivots_to_center_list, frontier_sides 234 | ) 235 | 236 | # error handling: too small image area that cannot put the word anywhere anymore 237 | if len(center_position_candidates) == 0: 238 | raise Exception( 239 | "No available position found. Try to reduce font size or expand image size." 240 | ) 241 | 242 | # find the best position 243 | best_position = self._try_put_all_candidates( 244 | center_position_candidates, word.text_size, position_from 245 | ) 246 | 247 | # to left-top position 248 | best_position_left_top = ( 249 | best_position[0] - word.text_size[0] / 2, 250 | best_position[1] - word.text_size[1] / 2, 251 | ) 252 | 253 | return best_position_left_top 254 | 255 | def _compute_candidates( 256 | self, 257 | pivots_to_center_list: Iterable[Iterable[Vector]], 258 | frontier_sides: Iterable[Iterable[Vector]], 259 | ) -> list[tuple[int, int]]: 260 | """ 261 | Compute all candidates of the center position 262 | 263 | :param Iterable[Iterable[Vector]] pivots_to_center_list: 264 | List of vectors from the pivot to the center of the word 265 | :param Iterable[Iterable[Vector]] 266 | List of vectors from the frontier to the center of the word 267 | :return: Candidates of the center position 268 | """ 269 | 270 | center_position_candidates = [] 271 | for cnt in range(4): 272 | center_position_candidates.extend( 273 | self._get_candidates_from_one_side( 274 | pivots_to_center_list[cnt], frontier_sides[cnt] 275 | ) 276 | ) 277 | 278 | return center_position_candidates 279 | 280 | def _get_candidates_from_one_side( 281 | self, 282 | pivots_to_center: Iterable[Vector], 283 | points_on_side: Iterable[Vector], 284 | ) -> list[tuple[int, int]]: 285 | """ 286 | Get all candidates of the center position from one side 287 | 288 | Intended to be used in `find_best_position()` 289 | 290 | :param Iterable[Vector] pivots_to_center: 291 | Vector of (center of the word) - (pivot) 292 | :param Iterable[Vector] points_on_side: 293 | Points on the side 294 | :return: Candidates of the center position 295 | :rtype: list[tuple[int, int]] 296 | """ 297 | 298 | # get all candidate center positions 299 | candidates = [] 300 | for point_on_side in points_on_side: 301 | for pivot_to_center in pivots_to_center: 302 | candidates.append(point_on_side + pivot_to_center) 303 | 304 | return candidates 305 | 306 | def _try_put_all_candidates( 307 | self, 308 | center_positions: Iterable[tuple[int, int]], 309 | size: tuple[int, int], 310 | position_from: tuple[int, int], 311 | ) -> tuple[int, int]: 312 | """ 313 | Try to put the word at the gived place and evaluate the score, and return the best scored position 314 | 315 | :param Iterable[tuple[int,int]] center_positions: 316 | Candidate list of center points of the word 317 | :param tuple[int,int] size: Size of the word 318 | :param tuple[int,int] position_from: 319 | Position of the center of the word comming from 320 | :return: Best center position 321 | :rtype: tuple[int, int] 322 | """ 323 | 324 | results_evaluation = [ 325 | self._try_put_position(center_position, size, position_from) 326 | for center_position in center_positions 327 | ] 328 | 329 | # find best score 330 | best_position = None 331 | best_score = -float("inf") 332 | for cnt, result_evaluation in enumerate(results_evaluation): 333 | if (result_evaluation is not None) and ( 334 | result_evaluation > best_score 335 | ): 336 | best_score = result_evaluation 337 | best_position = center_positions[cnt] 338 | 339 | # guard 340 | if best_position is None: 341 | raise Exception( 342 | "No available position found. Try to reduce font size or expand image size." 343 | ) 344 | 345 | return best_position 346 | 347 | def _is_hitting_other_words( 348 | self, 349 | center_position: tuple[int, int] | Vector, 350 | size: tuple[int, int] | Vector, 351 | ) -> bool: 352 | """ 353 | Check if the given rect is hitting with other words 354 | 355 | :param tuple[int,int] | Vector center_position: Center point of the word 356 | :param tuple[int,int] | Vector size: Size of the word 357 | :return: True if the center point is hitting with other words 358 | :rtype: bool 359 | """ 360 | # ensure to Vector 361 | if center_position.__class__ == tuple: 362 | center_position = Vector(center_position) 363 | if size.__class__ == tuple: 364 | size = Vector(size) 365 | 366 | left_top = center_position - size / 2 367 | right_bottom = center_position + size / 2 368 | 369 | return is_rect_hitting_rects( 370 | Rect( 371 | left_top.convert_to_tuple(), 372 | right_bottom.convert_to_tuple(), 373 | ), 374 | self.rects, 375 | ) 376 | 377 | def _try_put_position( 378 | self, 379 | center_position: tuple[float, float], 380 | size: tuple[float, float], 381 | position_from: tuple[float, float], 382 | ) -> float: 383 | """ 384 | Evaluate the position the word if putted 385 | 386 | :param tuple[float,float] center_position: Position of the center of the word 387 | :param tuple[float,float] size: Size of the word 388 | :param tuple[float,float] position_from: Position of the center of the word comming from 389 | :return: Evaluation score. Smaller is the better 390 | :rtype: float 391 | """ 392 | 393 | # if hitting with other word... 394 | if self._is_hitting_other_words(center_position, size): 395 | # ...skip this position 396 | return None 397 | 398 | score = self._evaluate_position(position_from, center_position) 399 | 400 | return score 401 | --------------------------------------------------------------------------------