Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
<!-- <script src="https://kit.fontawesome.com/d20edc211b.js" crossorigin="anonymous"></script>
<img src="docs/img/logo.svg" alt="IKPyKit" width="120" align="left" style="margin-right: 20px;">

<div style="margin-bottom: 10px;">
<img src="img/ikpykit_logo_1.jpg#only-light" align="left" style="margin-bottom: 20px; margin-top: 0px;">
<img src="img/ikpykit_logo_1.jpg#only-dark" align="left" style="margin-bottom: 20px; margin-top: 0px;">
</div> -->

<!-- <div style="clear: both;"></div> -->
<div style="clear: both;"></div>

![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)
[![PyPI](https://img.shields.io/pypi/v/ikpykit)](https://pypi.org/project/ikpykit/)
Expand Down
9 changes: 2 additions & 7 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,9 @@ description: >-
IKPyKit is a scikit-learn compatible Python library implementing Isolation Kernel methods for anomaly detection, clustering and change detection.
---

<!-- <script src="https://kit.fontawesome.com/d20edc211b.js" crossorigin="anonymous"></script>
<img src="img/logo.svg" alt="IKPyKit" width="120" align="left" style="margin-right: 20px;">

<div style="margin-bottom: 10px;">
<img src="img/ikpykit_logo_1.jpg#only-light" align="left" style="margin-bottom: 20px; margin-top: 0px;">
<img src="img/ikpykit_logo_1.jpg#only-dark" align="left" style="margin-bottom: 20px; margin-top: 0px;">
</div> -->

<!-- <div style="clear: both;"></div> -->
<div style="clear: both;"></div>

![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)
[![PyPI](https://img.shields.io/pypi/v/ikpykit)](https://pypi.org/project/ikpykit/)
Expand Down
Binary file added docs/img/favicon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed docs/img/favicon.jpg
Binary file not shown.
Binary file removed docs/img/favicon.png
Binary file not shown.
Binary file removed docs/img/ikt_logo_2.jpg
Binary file not shown.
Binary file added docs/img/logo-180.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/logo-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions docs/img/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed docs/img/pyikt_logo_1.jpg
Binary file not shown.
41 changes: 41 additions & 0 deletions docs/stylesheets/extra.css
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,44 @@ Page Actions (Copy as Markdown)
height: 0.9rem;
fill: currentcolor;
}

/*
===============================
Brand colours
===============================

The palette in mkdocs.yml is set to "custom", which defers to the variables
below. These are scikit-learn's blue and orange: IKPyKit implements the
scikit-learn API, and that pair is how the surrounding ecosystem signals it.
The logo is built from the same two values.
*/

:root {
--ik-blue: #3499cd;
--ik-blue-dark: #2a7fae;
--ik-blue-light: #6bb8e4;
--ik-orange: #f89939;
--ik-orange-dark: #e07f1f;
}

[data-md-color-scheme="default"] {
--md-primary-fg-color: var(--ik-blue);
--md-primary-fg-color--light: var(--ik-blue-light);
--md-primary-fg-color--dark: var(--ik-blue-dark);
--md-primary-bg-color: #ffffff;
--md-primary-bg-color--light: rgba(255, 255, 255, 0.7);
--md-accent-fg-color: var(--ik-orange-dark);
--md-accent-fg-color--transparent: rgba(248, 153, 57, 0.1);
}

[data-md-color-scheme="slate"] {
/* On the dark scheme the header goes near-black, so the blue is kept for
links and highlights only -- a mid blue header washes out against it. */
--md-primary-fg-color: #1b2027;
--md-primary-fg-color--light: var(--ik-blue-light);
--md-primary-fg-color--dark: #12161b;
--md-primary-bg-color: #ffffff;
--md-accent-fg-color: var(--ik-orange);
--md-accent-fg-color--transparent: rgba(248, 153, 57, 0.1);
--md-typeset-a-color: var(--ik-blue-light);
}
16 changes: 9 additions & 7 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ plugins:
# to include the version prefix during a versioned deploy.
- social:
cards_layout_options:
background_color: "#3f51b5" # matches the indigo primary
background_color: "#3499cd" # matches the primary and the logo
# Must come after mkdocstrings and mkdocs-jupyter: this plugin converts the
# rendered HTML back to Markdown, so it needs their output to already exist.
# Otherwise the API pages would export as bare `::: ikpykit.X` directives.
Expand Down Expand Up @@ -179,7 +179,7 @@ plugins:
theme:
name: material
custom_dir: docs/overrides
favicon: img/favicon.png
favicon: img/logo.svg
icon:
repo: fontawesome/brands/github
features:
Expand All @@ -198,17 +198,19 @@ theme:
text: Open Sans
code: Ubuntu Mono
highlightjs: true
logo: img/favicon.png
logo: img/logo.svg
palette:
- scheme: default
primary: indigo
accent: indigo
# "custom" defers to the values in stylesheets/extra.css, which carry
# scikit-learn's exact blue and orange -- the same pair the logo uses.
primary: custom
accent: custom
toggle:
icon: material/weather-sunny
name: Switch to dark mode
- scheme: slate
primary: black
accent: black
primary: custom
accent: custom
toggle:
icon: material/weather-night
name: Switch to light mode
Expand Down
217 changes: 217 additions & 0 deletions scripts/gen_logo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""Draw the IKPyKit logo by running Isolation Kernel on a small point set.

The mark is an "ik" monogram over a Voronoi partition, and the partition is not
decoration: it is the output of one aNNE estimator, the same partition the
kernel uses to measure similarity. Cells are shaded by area, and the largest --
the one whose point sits furthest from any neighbour, and so is easiest to
isolate -- is the orange one.

The palette is scikit-learn's blue and orange. IKPyKit implements the
scikit-learn API, and that pair is how the surrounding ecosystem signals it;
the form is a monogram rather than scikit-learn's blobs, so the mark reads as a
sibling rather than as something official.

Nothing is hand-drawn, so the mark regenerates at any size and the palette or
the cell count can be changed by editing the constants below.

python scripts/gen_logo.py # write the assets into docs/img/
python scripts/gen_logo.py --preview # contact sheet of other candidates
"""

from __future__ import annotations

import argparse
import pathlib

import cairosvg
import numpy as np
from scipy.spatial import Voronoi

from ikpykit.kernel import IsoKernel

OUT_DIR = pathlib.Path("docs/img")
SIZE = 512

# scikit-learn's blue, lightened into a four step ramp. Their blue is a mid
# tone, so the ramp deliberately stops short of navy -- a dark step reads as a
# different family and weighs one corner of the mark down.
BLUE = ("#9ed2ee", "#6bb8e4", "#3499cd", "#2a7fae")
ORANGE = "#f89939"
WHITE = "#ffffff"

# Chosen from the candidates --preview renders, on composition: balanced cells,
# no slivers, and the orange cell clear of the monogram's counters.
CELLS = 4
RANDOM_STATE = 3

# The monogram, drawn as constant weight strokes. The k's arms are one path
# whose vertex sits on the stem's centre line, and the stem is painted after
# them, so the join between arms is hidden rather than spiking out to the left.
STROKE = 52
ARMS = "M 401 178 L 245 290 L 401 392"
K_STEM = "M 245 120 L 245 392"
I_STEM = "M 139 200 L 139 392"
DOT = (139, 150, 31)


def sample_points(random_state: int, n: int = 160) -> np.ndarray:
rng = np.random.RandomState(random_state)
centres = rng.uniform(0.2, 0.8, size=(4, 2))
points = np.vstack([c + rng.randn(n // 4, 2) * 0.13 for c in centres])
return np.clip(points, 0.06, 0.94)


def partition(n_cells: int, random_state: int) -> list[np.ndarray]:
"""Return the Voronoi cells one aNNE estimator lays over the unit square.

Mirroring the centres across each edge makes every cell finite and exactly
bounded by the square, which avoids clipping infinite ridges by hand.
"""
centres = (
IsoKernel(
method="anne",
n_estimators=1,
max_samples=n_cells,
random_state=random_state,
)
.fit(sample_points(random_state))
.iso_kernel_.center_data
)
blocks = [centres]
for axis, value in ((0, 0.0), (0, 1.0), (1, 0.0), (1, 1.0)):
mirror = centres.copy()
mirror[:, axis] = 2 * value - mirror[:, axis]
blocks.append(mirror)
diagram = Voronoi(np.vstack(blocks))
return [
diagram.vertices[diagram.regions[diagram.point_region[i]]]
for i in range(len(centres))
]


def area(poly: np.ndarray) -> float:
x, y = poly[:, 0], poly[:, 1]
return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))


def fatness(poly: np.ndarray) -> float:
"""1 for a circle, near 0 for a sliver. Keeps thin wedges out of the mark."""
perimeter = np.hypot(*(np.roll(poly, -1, 0) - poly).T).sum()
return 4 * np.pi * area(poly) / perimeter**2 if perimeter else 0.0


def composition_score(cells: list[np.ndarray]) -> float:
"""Rank candidates so --preview shows the ones worth looking at first."""
areas = np.array([area(c) for c in cells])
return min(fatness(c) for c in cells) * 2 + (
1 - abs(areas.max() / areas.sum() - 0.4) * 4
)


def render(
n_cells: int = CELLS, random_state: int = RANDOM_STATE, size: int = SIZE
) -> str:
cells = partition(n_cells, random_state)
areas = np.array([area(c) for c in cells])
rank = np.argsort(np.argsort(-areas))
isolated = int(np.argmax(areas))

polygons = []
for i, cell in enumerate(cells):
points = " ".join(f"{x * size:.1f},{(1 - y) * size:.1f}" for x, y in cell)
fill = ORANGE if i == isolated else BLUE[min(rank[i], len(BLUE) - 1)]
polygons.append(f'<polygon points="{points}" fill="{fill}"/>')

cx, cy, r = DOT
joined = "\n ".join(polygons)
return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}" \
role="img" aria-label="IKPyKit">
<defs>
<clipPath id="disc"><circle cx="256" cy="256" r="230"/></clipPath>
</defs>
<g id="partition" clip-path="url(#disc)">
<g stroke="{WHITE}" stroke-width="8" stroke-linejoin="round">
{joined}
</g>
</g>
<!-- The mark is mostly blue and the site header is too, so without this ring
the disc dissolves into it. On a light background the ring is invisible. -->
<circle cx="256" cy="256" r="230" fill="none" stroke="{WHITE}" stroke-width="16"/>
<g id="monogram">
<g stroke="{WHITE}" stroke-width="{STROKE}" fill="none" stroke-linejoin="round">
<path d="{ARMS}"/>
<path d="{K_STEM}"/>
<path d="{I_STEM}"/>
</g>
<!-- The ring keeps the dot visible when it falls on the orange cell. -->
<circle cx="{cx}" cy="{cy}" r="{r}" fill="{ORANGE}" stroke="{WHITE}" stroke-width="11"/>
</g>
</svg>
"""


def write_assets() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
mark = render()
written = ["logo.svg"]
(OUT_DIR / "logo.svg").write_text(mark)
# PNG fallbacks: 512 for social cards and PyPI, 180 for iOS home screens,
# 32 for browsers that still ask for a raster favicon.
for name, width in (
("logo-512.png", 512),
("logo-180.png", 180),
("favicon-32.png", 32),
):
cairosvg.svg2png(
bytestring=mark.encode(), write_to=str(OUT_DIR / name), output_width=width
)
written.append(name)
print("\n".join(f"wrote {OUT_DIR / name}" for name in written))


def write_preview(n_cells: int, count: int) -> None:
import io

from PIL import Image

ranked = sorted(
((composition_score(partition(n_cells, s)), s) for s in range(40)),
key=lambda pair: -pair[0],
)[:count]

sheet = Image.new("RGBA", (220 * count, 290), (255, 255, 255, 255))
for i, (score, state) in enumerate(ranked):
png = cairosvg.svg2png(
bytestring=render(n_cells, state).encode(), output_width=210
)
big = Image.open(io.BytesIO(png)).convert("RGBA")
flat = Image.new("RGBA", big.size, (255, 255, 255, 255))
flat.alpha_composite(big)
sheet.alpha_composite(flat, (220 * i, 0))
for j, px in enumerate((16, 24, 32)):
sheet.alpha_composite(
flat.resize((px, px), Image.LANCZOS), (220 * i + 10 + j * 60, 225)
)
print(f"random_state={state:<3} score={score:.3f}")
path = pathlib.Path(f"/tmp/ikpykit-logo-{n_cells}-cells.png")
sheet.save(path)
print(f"contact sheet: {path}")


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--preview", action="store_true", help="render other candidates"
)
parser.add_argument("--cells", type=int, default=CELLS)
parser.add_argument("--count", type=int, default=6)
args = parser.parse_args()

if args.preview:
write_preview(args.cells, args.count)
else:
write_assets()


if __name__ == "__main__":
main()
Loading