├── 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 | [](https://app.codacy.com/gh/konbraphat51/AnimatedWordCloud/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
9 | [](https://github.com/konbraphat51/AnimatedWordCloud/actions/workflows/python-tester.yml)
10 | [](https://github.com/konbraphat51/AnimatedWordCloud/actions/workflows/lint.yml)
11 | [](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 | 
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 | [](https://app.codacy.com/gh/konbraphat51/AnimatedWordCloud/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
5 | [](https://github.com/konbraphat51/AnimatedWordCloud/actions/workflows/python-tester.yml)[](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 | 
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 | [](https://github.com/konbraphat51)
128 |
129 | - [SuperHotDogCat](https://github.com/SuperHotDogCat): Author
130 | [](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 |
--------------------------------------------------------------------------------