-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Vincent Berenz
committed
Oct 10, 2024
1 parent
4c7025a
commit 09eb4cf
Showing
48 changed files
with
4,153 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
name: unit tests | ||
|
||
on: | ||
push: | ||
branches: | ||
- master | ||
pull_request: | ||
branches: | ||
- master | ||
workflow_dispatch: | ||
|
||
jobs: | ||
test: | ||
runs-on: ubuntu-20.04 | ||
|
||
strategy: | ||
matrix: | ||
python-version: [3.9.2] | ||
|
||
steps: | ||
- uses: actions/checkout@v2 | ||
- name: Set up Python ${{ matrix.python-version }} | ||
uses: actions/setup-python@v2 | ||
with: | ||
python-version: ${{ matrix.python-version }} | ||
- name: Install dependencies | ||
run: | | ||
python -m pip install --upgrade pip | ||
pip install poetry | ||
poetry install --with test | ||
- name: Run tests | ||
run: | | ||
poetry run pytest |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
""" | ||
Definition of project-level constants. | ||
""" | ||
|
||
from typing import Tuple | ||
|
||
# Files in general. | ||
FILE_PERMISSIONS: int = 0o755 # Octal integer. | ||
|
||
# HD image. | ||
IMAGE_FILE_FORMATS: Tuple[str, ...] = ("npy", "tiff", "jpg", "jpeg") | ||
|
||
# Thumbnail image. | ||
THUMBNAIL_DIR_NAME: str = "thumbnails" | ||
THUMBNAIL_FILE_FORMAT: str = "jpeg" | ||
THUMBNAIL_WIDTH: int = 200 # In pixels. | ||
|
||
# Zip. | ||
ZIP_DIR_NAME: str = "zip" | ||
|
||
# Video. | ||
VIDEO_FILE_NAME: str = "day_summary.webm" | ||
|
||
# Weather summary. | ||
WEATHER_SUMMARY_FILE_NAME: str = "weathers.toml" | ||
|
||
# Format patterns. | ||
|
||
# formats for filename, e.g. nightskycamX_2024_09_26_13_57_50.jpeg | ||
DATE_FORMAT_FILE: str = "%Y_%m_%d" | ||
TIME_FORMAT_FILE: str = "%H_%M_%S" | ||
DATETIME_FORMATS: Tuple[str, ...] = ("%d_%m_%Y_%H_%M_%S", "%Y_%m_%d_%H_%M_%S") | ||
|
||
# formats for displaying on the website | ||
DATE_FORMAT: str = "%Y-%m-%d" | ||
TIME_FORMAT: str = "%H:%M:%S" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
from pathlib import Path | ||
import tempfile | ||
|
||
import PIL.Image as PILImage | ||
import cv2 | ||
import numpy as np | ||
import numpy.typing as npt | ||
|
||
|
||
def _bits_reduction(data: npt.NDArray, target: type) -> npt.NDArray: | ||
original_max = np.iinfo(data.dtype).max | ||
target_max = np.iinfo(target).max | ||
ratio = target_max / original_max | ||
return (data * ratio).astype(target) | ||
|
||
|
||
def _to_8bits(image: npt.NDArray) -> npt.NDArray: | ||
return _bits_reduction(image, np.uint8) | ||
|
||
|
||
def npy_file_to_pil(image_path: Path) -> PILImage.Image: | ||
""" | ||
Read the file (expected to be an .npy file) and | ||
return a corresponding instance of PIL Image. | ||
It first converts the image to 8 bits. | ||
""" | ||
img_array = np.load(image_path) | ||
img_array = _to_8bits(img_array) | ||
|
||
# Create a temporary directory for intermediary tiff file. | ||
with tempfile.TemporaryDirectory() as tmp_dir: | ||
tiff_file_path = Path(tmp_dir) / f"{image_path.stem}.tiff" | ||
cv2.imwrite(str(tiff_file_path), img_array, [cv2.IMWRITE_TIFF_COMPRESSION, 1]) | ||
return PILImage.open(str(tiff_file_path)) | ||
|
||
|
||
def npy_file_to_numpy(image_path: Path) -> np.ndarray: | ||
""" | ||
Read the file (expected to be an .npy file) and | ||
return a corresponding numpy array, converted to 8 bits. | ||
""" | ||
img_array = np.load(image_path) | ||
img_array = _to_8bits(img_array) | ||
return img_array |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import os | ||
from pathlib import Path | ||
from typing import Optional | ||
|
||
|
||
def folder_has_changed( | ||
folder: Path, history: Optional[dict[Path, Optional[float]]] | ||
) -> bool: | ||
""" | ||
Check whether the last-modified-time of the directory changed | ||
since the previous check. | ||
If there is no history of a previous check: | ||
return True and initialise history. | ||
Parameters | ||
---------- | ||
folder | ||
Path to directory. | ||
history | ||
Last modification times found in previous check: | ||
- key: | ||
Path to directory. | ||
- value: | ||
Time of last modification of directory. | ||
""" | ||
history = history if history else {} | ||
|
||
# Float: number of seconds since epoch. | ||
last_modified_time = os.path.getmtime(folder) | ||
|
||
# Set to dummy value when key does NOT exist. | ||
previous_modified_time = history.get(folder, None) | ||
|
||
# If the folder has NO previous history | ||
# OR was modified since previous history, | ||
# update the history. | ||
if not previous_modified_time or previous_modified_time < last_modified_time: | ||
history[folder] = last_modified_time | ||
return True | ||
|
||
return False |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,226 @@ | ||
import datetime as dt | ||
from pathlib import Path | ||
from typing import Any, Dict, Optional, cast | ||
|
||
import toml | ||
|
||
from .constants import ( | ||
DATE_FORMAT, | ||
DATE_FORMAT_FILE, | ||
IMAGE_FILE_FORMATS, | ||
THUMBNAIL_DIR_NAME, | ||
THUMBNAIL_FILE_FORMAT, | ||
VIDEO_FILE_NAME, | ||
) | ||
|
||
|
||
class Image: | ||
def __init__(self) -> None: | ||
|
||
# Stem of file name (without extension). | ||
self.filename_stem: Optional[str] = None | ||
# Datetime instance. | ||
# NOT named `datetime` to avoid confusion with package/class. | ||
self.date_and_time: Optional[dt.datetime] = None | ||
# System name. | ||
self.system: Optional[str] = None | ||
|
||
# Absolute path to directory containing the HD images. | ||
# Also contains sub-directory containing the thumbnail images. | ||
self.dir_path: Optional[Path] = None | ||
|
||
@property | ||
def filename(self) -> Optional[str]: | ||
""" | ||
Alias for filename_stem | ||
""" | ||
return self.filename_stem | ||
|
||
@property | ||
def date(self) -> Optional[dt.datetime]: | ||
""" | ||
Alias for date_and_time | ||
""" | ||
return self.date_and_time | ||
|
||
@property | ||
def thumbnail(self) -> Optional[Path]: | ||
""" | ||
Absolute path to thumbnail image file. | ||
""" | ||
if self.dir_path is None: | ||
return None | ||
|
||
file_path = ( | ||
self.dir_path | ||
/ THUMBNAIL_DIR_NAME | ||
/ f"{self.filename_stem}.{THUMBNAIL_FILE_FORMAT}" | ||
) | ||
if file_path.is_file(): | ||
return file_path | ||
|
||
return None | ||
|
||
@property | ||
def video(self) -> Optional[Path]: | ||
""" | ||
Absolute path to video file. | ||
""" | ||
if self.dir_path is None: | ||
return None | ||
|
||
file_path = self.dir_path / THUMBNAIL_DIR_NAME / VIDEO_FILE_NAME | ||
if file_path.is_file(): | ||
return file_path | ||
|
||
return None | ||
|
||
@property | ||
def hd(self) -> Optional[Path]: | ||
""" | ||
Absolute path to HD image file. | ||
""" | ||
if self.dir_path is None: | ||
return None | ||
|
||
for f in IMAGE_FILE_FORMATS: | ||
file_path = self.dir_path / f"{self.filename_stem}.{f}" | ||
if file_path.is_file(): | ||
return file_path | ||
|
||
return None | ||
|
||
@property | ||
def path(self) -> Optional[Path]: | ||
""" | ||
Alias for hd | ||
""" | ||
return self.hd | ||
|
||
@property | ||
def meta_path(self) -> Optional[Path]: | ||
""" | ||
Absolute path to meta data file. | ||
""" | ||
if self.dir_path is None: | ||
return None | ||
|
||
file_path = self.dir_path / f"{self.filename_stem}.toml" | ||
if file_path.is_file(): | ||
return file_path | ||
|
||
return None | ||
|
||
@property | ||
def meta(self) -> Dict[str, Any]: | ||
|
||
if self.dir_path is None or self.filename_stem is None: | ||
return {} | ||
meta_file = self.meta_path | ||
if not meta_file: | ||
return {} | ||
if not meta_file.is_file(): | ||
return {} | ||
try: | ||
meta = toml.load(meta_file) | ||
except toml.decoder.TomlDecodeError as e: | ||
meta = {"error": f"failed to read {meta_file}: {e}"} | ||
return meta | ||
|
||
@property | ||
def nightstart_date(self) -> Optional[dt.date]: | ||
""" | ||
Start date of the night. | ||
Purpose: | ||
Utility for bundling images per night instead of date. | ||
Context: | ||
The date switches at midnight. | ||
""" | ||
if self.date_and_time is None: | ||
return None | ||
hour = self.date_and_time.hour | ||
|
||
# Before noon, | ||
# therefore assign to date of previous day. | ||
if hour < 12: | ||
return (self.date_and_time - dt.timedelta(days=1)).date() | ||
|
||
return self.date_and_time.date() | ||
|
||
@property | ||
def day(self) -> Optional[dt.date]: | ||
""" | ||
Alias for nightstart_date | ||
""" | ||
return self.nightstart_date | ||
|
||
@property | ||
def day_as_str(self) -> Optional[str]: | ||
if self.day is None: | ||
return None | ||
return self.day.strftime(DATE_FORMAT_FILE) | ||
|
||
@property | ||
def nightstart_date_as_str(self) -> Optional[str]: | ||
""" | ||
String representation of the property `nightstart_date`. | ||
""" | ||
nightstart_date = self.nightstart_date | ||
if nightstart_date is None: | ||
return None | ||
return nightstart_date.strftime(DATE_FORMAT) | ||
|
||
@staticmethod | ||
def date_from_str(date: str) -> dt.date: | ||
""" | ||
Get date instance from its string representation. | ||
""" | ||
return dt.datetime.strptime(date, DATE_FORMAT_FILE).date() | ||
|
||
@staticmethod | ||
def day_from_str(day: str) -> dt.date: | ||
""" | ||
Alias for date_from_str | ||
""" | ||
return dt.datetime.strptime(day, DATE_FORMAT_FILE).date() | ||
|
||
@property | ||
def datetime_as_str( | ||
self, datetime_format: str = "%b. %d, %Y, %-I:%M %p" | ||
) -> Optional[str]: | ||
""" | ||
String representation of the attribute `date_and_time`. | ||
""" | ||
if self.date is None: | ||
return None | ||
return self.date.strftime(datetime_format) | ||
|
||
def to_dict(self) -> Dict[str, str]: | ||
""" | ||
Get dictionary representation of image instance. | ||
""" | ||
r: Dict[str, str] = {} | ||
r["path"] = str(self.hd) | ||
r["thumbnail"] = str(self.thumbnail) | ||
r["video"] = str(self.video) | ||
r["date_as_str"] = str(self.datetime_as_str) | ||
r["day_as_str"] = str(self.nightstart_date_as_str) | ||
r["system"] = str(self.system) | ||
r["meta"] = repr(self.meta) | ||
r["meta_path"] = str(self.meta_path) | ||
return r | ||
|
||
def __eq__(self, other: object) -> bool: | ||
return self.date_and_time == Image.date_and_time | ||
|
||
def __gt__(self, other: object) -> bool: | ||
other_ = cast("Image", other) | ||
if self.date_and_time is None: | ||
return False | ||
if other_.date_and_time is None: | ||
return True | ||
if self.date_and_time >= other_.date_and_time: | ||
return True | ||
return False |
Oops, something went wrong.