seedbuddy 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seedbuddy/__init__.py +1 -0
- seedbuddy/__main__.py +3 -0
- seedbuddy/cli.py +102 -0
- seedbuddy/photos/__init__.py +1 -0
- seedbuddy/photos/dish-recipe.json +12 -0
- seedbuddy/photos/dish.py +58 -0
- seedbuddy/photos/features.py +57 -0
- seedbuddy/photos/fingerprint.py +74 -0
- seedbuddy/photos/photographs.py +27 -0
- seedbuddy/photos/record.py +177 -0
- seedbuddy/photos/review.css +420 -0
- seedbuddy/photos/review.js +382 -0
- seedbuddy/photos/review.py +260 -0
- seedbuddy/photos/review_page.html +54 -0
- seedbuddy/photos/sharpness.py +117 -0
- seedbuddy/photos/sorting.py +228 -0
- seedbuddy/photos/suitability.py +178 -0
- seedbuddy-0.2.0.dist-info/METADATA +116 -0
- seedbuddy-0.2.0.dist-info/RECORD +21 -0
- seedbuddy-0.2.0.dist-info/WHEEL +4 -0
- seedbuddy-0.2.0.dist-info/entry_points.txt +2 -0
seedbuddy/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Prepare culture-dish photographs for a SeedBuddy workbench."""
|
seedbuddy/__main__.py
ADDED
seedbuddy/cli.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from seedbuddy.photos.record import (
|
|
9
|
+
has_confirmations,
|
|
10
|
+
load_record,
|
|
11
|
+
record_of,
|
|
12
|
+
save_record,
|
|
13
|
+
)
|
|
14
|
+
from seedbuddy.photos.review import serve_review
|
|
15
|
+
from seedbuddy.photos.sorting import sort_photographs
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _sort(args: argparse.Namespace) -> int:
|
|
19
|
+
folder = Path(args.folder).expanduser().resolve()
|
|
20
|
+
if not folder.is_dir():
|
|
21
|
+
raise ValueError(f"{folder} is not a folder")
|
|
22
|
+
output = _unconfirmed(args.output)
|
|
23
|
+
record = record_of(sort_photographs(folder, progress=_progress))
|
|
24
|
+
if output is not None:
|
|
25
|
+
save_record(output, record)
|
|
26
|
+
json.dump(record, sys.stdout, indent=2, ensure_ascii=False)
|
|
27
|
+
sys.stdout.write("\n")
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _review(args: argparse.Namespace) -> int:
|
|
32
|
+
path = Path(args.file).expanduser()
|
|
33
|
+
if not path.is_file():
|
|
34
|
+
raise ValueError(f"{path} is not a file")
|
|
35
|
+
serve_review(path)
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _unconfirmed(output: str | None) -> Path | None:
|
|
40
|
+
"""Where the record may be written: a file that holds no confirmation."""
|
|
41
|
+
if output is None:
|
|
42
|
+
return None
|
|
43
|
+
path = Path(output).expanduser()
|
|
44
|
+
if path.is_file() and has_confirmations(load_record(path)):
|
|
45
|
+
raise ValueError(f"{path} already holds confirmations")
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _progress(line: str) -> None:
|
|
50
|
+
print(line, file=sys.stderr)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parser() -> argparse.ArgumentParser:
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
prog="seedbuddy",
|
|
56
|
+
description="Prepare culture-dish photographs for a SeedBuddy workbench.",
|
|
57
|
+
)
|
|
58
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
59
|
+
|
|
60
|
+
photos = commands.add_parser("photos", help="Work with a folder of photographs")
|
|
61
|
+
photo_commands = photos.add_subparsers(dest="photos_command", required=True)
|
|
62
|
+
sorting = photo_commands.add_parser(
|
|
63
|
+
"sort",
|
|
64
|
+
help="Sort the photographs into dishes and suggest one to use for each",
|
|
65
|
+
description=(
|
|
66
|
+
"Sort the photographs in FOLDER by the dish they show, the one whose "
|
|
67
|
+
"seeds are sharpest first, and say whether the seeds in each can be "
|
|
68
|
+
"told apart well enough to count. Prints JSON; files are left "
|
|
69
|
+
"untouched."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
sorting.add_argument("folder", metavar="FOLDER")
|
|
73
|
+
sorting.add_argument(
|
|
74
|
+
"-o",
|
|
75
|
+
"--output",
|
|
76
|
+
metavar="FILE",
|
|
77
|
+
help=(
|
|
78
|
+
"also write the JSON to FILE; refused when FILE already holds confirmations"
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
sorting.set_defaults(handler=_sort)
|
|
82
|
+
reviewing = photo_commands.add_parser(
|
|
83
|
+
"review",
|
|
84
|
+
help="Confirm which photograph of each dish to use",
|
|
85
|
+
description=(
|
|
86
|
+
"Serve a page to confirm or correct the suggestions in FILE. "
|
|
87
|
+
"Confirmations are written back into FILE. The photographs stay "
|
|
88
|
+
"where they are."
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
reviewing.add_argument("file", metavar="FILE")
|
|
92
|
+
reviewing.set_defaults(handler=_review)
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: list[str] | None = None) -> int:
|
|
97
|
+
args = _parser().parse_args(argv)
|
|
98
|
+
try:
|
|
99
|
+
return args.handler(args)
|
|
100
|
+
except (OSError, ValueError) as error:
|
|
101
|
+
print(f"error: {error}", file=sys.stderr)
|
|
102
|
+
return 2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Sort a folder of dish photographs and confirm which of each dish to use."""
|
seedbuddy/photos/dish.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Locate the dish circle in a grayscale photograph.
|
|
2
|
+
|
|
3
|
+
The dish recipe is byte-identical to the workbench recipe.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
RECIPE = json.loads(files("seedbuddy.photos").joinpath("dish-recipe.json").read_text())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class DishCircle:
|
|
21
|
+
x: float
|
|
22
|
+
y: float
|
|
23
|
+
radius: float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def locate_dish(gray: np.ndarray) -> DishCircle | None:
|
|
27
|
+
height, width = gray.shape
|
|
28
|
+
scale = min(1.0, RECIPE["thumbnailLongEdge"] / max(width, height))
|
|
29
|
+
size = (
|
|
30
|
+
max(1, math.floor(width * scale + 0.5)),
|
|
31
|
+
max(1, math.floor(height * scale + 0.5)),
|
|
32
|
+
)
|
|
33
|
+
small = cv2.resize(gray, size, interpolation=cv2.INTER_AREA)
|
|
34
|
+
small = cv2.GaussianBlur(small, (0, 0), RECIPE["sigma"])
|
|
35
|
+
side = min(small.shape)
|
|
36
|
+
circles = cv2.HoughCircles(
|
|
37
|
+
small,
|
|
38
|
+
cv2.HOUGH_GRADIENT,
|
|
39
|
+
dp=RECIPE["dp"],
|
|
40
|
+
minDist=side // 2,
|
|
41
|
+
param1=RECIPE["edgeThreshold"],
|
|
42
|
+
param2=RECIPE["centerThreshold"],
|
|
43
|
+
minRadius=int(side * RECIPE["minRadiusFraction"]),
|
|
44
|
+
maxRadius=int(side * RECIPE["maxRadiusFraction"]),
|
|
45
|
+
)
|
|
46
|
+
if circles is None:
|
|
47
|
+
return None
|
|
48
|
+
# The dish is the large circle near the middle of the frame.
|
|
49
|
+
centre = np.array([small.shape[1] / 2.0, small.shape[0] / 2.0])
|
|
50
|
+
x, y, radius = min(
|
|
51
|
+
circles[0],
|
|
52
|
+
key=lambda c: (
|
|
53
|
+
np.linalg.norm(c[:2] - centre) - RECIPE["radiusPreference"] * c[2]
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
return DishCircle(
|
|
57
|
+
x=float(x / scale), y=float(y / scale), radius=float(radius / scale)
|
|
58
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Distinctive points of a dish photograph and their counterparts in another."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from seedbuddy.photos.dish import DishCircle
|
|
9
|
+
|
|
10
|
+
# Features are found at a resolution where the dish has this radius, so every
|
|
11
|
+
# threshold expressed in dish radii holds for any camera.
|
|
12
|
+
WORKING_RADIUS = 700.0
|
|
13
|
+
# Removes fine periodic texture, such as a woven filter paper, whose repeats
|
|
14
|
+
# match anywhere; seeds are several times larger and survive.
|
|
15
|
+
TEXTURE_SIGMA = 1.0
|
|
16
|
+
CONTRAST_THRESHOLD = 0.02
|
|
17
|
+
RATIO_TEST = 0.75
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def features(
|
|
21
|
+
gray: np.ndarray, dish: DishCircle, radius: float, limit: int = 0
|
|
22
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
23
|
+
"""Points within ``radius`` dish radii of the centre, in full-resolution pixels.
|
|
24
|
+
|
|
25
|
+
``limit`` keeps only the strongest features; 0 keeps them all.
|
|
26
|
+
"""
|
|
27
|
+
scale = min(1.0, WORKING_RADIUS / dish.radius)
|
|
28
|
+
small = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
|
|
29
|
+
small = cv2.GaussianBlur(small, (0, 0), TEXTURE_SIGMA)
|
|
30
|
+
mask = np.zeros_like(small)
|
|
31
|
+
cv2.circle(
|
|
32
|
+
mask,
|
|
33
|
+
(round(dish.x * scale), round(dish.y * scale)),
|
|
34
|
+
round(dish.radius * scale * radius),
|
|
35
|
+
255,
|
|
36
|
+
-1,
|
|
37
|
+
)
|
|
38
|
+
sift = cv2.SIFT.create(nfeatures=limit, contrastThreshold=CONTRAST_THRESHOLD)
|
|
39
|
+
keypoints, descriptors = sift.detectAndCompute(small, mask)
|
|
40
|
+
points = np.array([keypoint.pt for keypoint in keypoints], np.float32)
|
|
41
|
+
if descriptors is None:
|
|
42
|
+
descriptors = np.empty((0, 128), np.float32)
|
|
43
|
+
return points.reshape(-1, 2) / scale, descriptors
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def matches(
|
|
47
|
+
descriptors_a: np.ndarray, descriptors_b: np.ndarray
|
|
48
|
+
) -> list[tuple[int, int]]:
|
|
49
|
+
"""Index pairs of features that resemble each other and nothing else."""
|
|
50
|
+
if len(descriptors_a) < 2 or len(descriptors_b) < 2:
|
|
51
|
+
return []
|
|
52
|
+
pairs = cv2.BFMatcher().knnMatch(descriptors_a, descriptors_b, k=2)
|
|
53
|
+
return [
|
|
54
|
+
(pair[0].queryIdx, pair[0].trainIdx)
|
|
55
|
+
for pair in pairs
|
|
56
|
+
if len(pair) == 2 and pair[0].distance < RATIO_TEST * pair[1].distance
|
|
57
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Recognize two photographs of one dish by the layout of everything in it.
|
|
2
|
+
|
|
3
|
+
Seeds, the substrate's outline and the lid's labels together form a layout no
|
|
4
|
+
other dish shares. Two photographs show the same dish when one rigid motion of
|
|
5
|
+
the camera carries that layout from one onto the other across the whole dish;
|
|
6
|
+
a patch that agrees alone, such as a date label every dish of a batch carries,
|
|
7
|
+
does not.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from seedbuddy.photos.dish import DishCircle
|
|
19
|
+
from seedbuddy.photos.features import features, matches
|
|
20
|
+
|
|
21
|
+
FEATURE_RADIUS = 0.95
|
|
22
|
+
MAX_FEATURES = 4000
|
|
23
|
+
# The lid sits above the seeds, so a change of camera height moves the two by
|
|
24
|
+
# slightly different amounts; the tolerance absorbs that parallax.
|
|
25
|
+
REPROJECTION_TOLERANCE = 0.014
|
|
26
|
+
MIN_INLIERS = 8
|
|
27
|
+
MIN_INLIER_SHARE = 0.5
|
|
28
|
+
MAX_SCALE_ERROR = 0.1
|
|
29
|
+
MIN_COVERAGE = 0.05
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Survey:
|
|
34
|
+
path: Path
|
|
35
|
+
dish: DishCircle
|
|
36
|
+
points: np.ndarray
|
|
37
|
+
"""Feature locations in full-resolution pixels."""
|
|
38
|
+
descriptors: np.ndarray
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def survey(path: Path, gray: np.ndarray, dish: DishCircle) -> Survey:
|
|
42
|
+
points, descriptors = features(gray, dish, FEATURE_RADIUS, MAX_FEATURES)
|
|
43
|
+
return Survey(path, dish, points, descriptors)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dish_motion(a: Survey, b: Survey) -> np.ndarray | None:
|
|
47
|
+
"""The camera motion carrying a onto b, when they show the same dish."""
|
|
48
|
+
paired = matches(a.descriptors, b.descriptors)
|
|
49
|
+
if len(paired) < MIN_INLIERS:
|
|
50
|
+
return None
|
|
51
|
+
points_a = a.points[[i for i, _ in paired]]
|
|
52
|
+
points_b = b.points[[j for _, j in paired]]
|
|
53
|
+
motion, inliers = cv2.estimateAffinePartial2D(
|
|
54
|
+
points_a,
|
|
55
|
+
points_b,
|
|
56
|
+
method=cv2.RANSAC,
|
|
57
|
+
ransacReprojThreshold=REPROJECTION_TOLERANCE * b.dish.radius,
|
|
58
|
+
)
|
|
59
|
+
if motion is None or inliers is None:
|
|
60
|
+
return None
|
|
61
|
+
agreed = inliers.ravel().astype(bool)
|
|
62
|
+
count = int(agreed.sum())
|
|
63
|
+
if count < MIN_INLIERS or count / len(paired) < MIN_INLIER_SHARE:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
scale = float(np.hypot(motion[0, 0], motion[1, 0]))
|
|
67
|
+
expected = b.dish.radius / a.dish.radius
|
|
68
|
+
if scale <= 0 or abs(np.log(scale / expected)) > MAX_SCALE_ERROR:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
spread = cv2.contourArea(cv2.convexHull(points_b[agreed]))
|
|
72
|
+
if spread / (np.pi * b.dish.radius**2) < MIN_COVERAGE:
|
|
73
|
+
return None
|
|
74
|
+
return motion
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""The photograph files a command is asked to read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
PHOTO_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
|
|
10
|
+
|
|
11
|
+
Progress = Callable[[str], None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Skipped:
|
|
16
|
+
path: Path
|
|
17
|
+
reason: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def photographs_in(folder: Path) -> list[Path]:
|
|
21
|
+
return sorted(
|
|
22
|
+
path
|
|
23
|
+
for path in folder.iterdir()
|
|
24
|
+
if path.is_file()
|
|
25
|
+
and not path.name.startswith(".")
|
|
26
|
+
and path.suffix.lower() in PHOTO_SUFFIXES
|
|
27
|
+
)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""The record of a sorted folder: each dish's photographs, and what a person confirmed.
|
|
2
|
+
|
|
3
|
+
Sorting suggests the first photograph of a dish and says whether its seeds can
|
|
4
|
+
be counted. A person's confirmation is kept beside that suggestion as the
|
|
5
|
+
dish's ``decision``, which names the photograph to use and whether it can be
|
|
6
|
+
counted.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from seedbuddy.photos.photographs import PHOTO_SUFFIXES
|
|
17
|
+
from seedbuddy.photos.sorting import Sorted
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def record_of(result: Sorted) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"dishes": [
|
|
23
|
+
{
|
|
24
|
+
"shots": [
|
|
25
|
+
{
|
|
26
|
+
"path": str(shot.path),
|
|
27
|
+
"sharpness": None
|
|
28
|
+
if shot.sharpness is None
|
|
29
|
+
else round(shot.sharpness, 3),
|
|
30
|
+
"suitable": shot.suitable,
|
|
31
|
+
"reason": shot.reason,
|
|
32
|
+
"alignment": [
|
|
33
|
+
[round(value, 5) for value in row] for row in shot.alignment
|
|
34
|
+
],
|
|
35
|
+
}
|
|
36
|
+
for shot in dish.shots
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
for dish in result.dishes
|
|
40
|
+
],
|
|
41
|
+
"skipped": [
|
|
42
|
+
{"path": str(item.path), "reason": item.reason} for item in result.skipped
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_record(path: Path) -> dict:
|
|
48
|
+
try:
|
|
49
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
except json.JSONDecodeError as error:
|
|
51
|
+
raise ValueError(f"{path} is not a record of sorted photographs") from error
|
|
52
|
+
if not _is_record(record):
|
|
53
|
+
raise ValueError(f"{path} is not a record of sorted photographs")
|
|
54
|
+
return record
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save_record(path: Path, record: dict) -> None:
|
|
58
|
+
descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}-")
|
|
59
|
+
try:
|
|
60
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
61
|
+
handle.write(json.dumps(record, indent=2, ensure_ascii=False) + "\n")
|
|
62
|
+
handle.flush()
|
|
63
|
+
os.fsync(handle.fileno())
|
|
64
|
+
os.replace(temporary, path)
|
|
65
|
+
finally:
|
|
66
|
+
Path(temporary).unlink(missing_ok=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def has_confirmations(record: dict) -> bool:
|
|
70
|
+
return any(dish.get("decision") is not None for dish in record["dishes"])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def confirm(
|
|
74
|
+
record: dict, dish: int, shot: int, suitable: bool | None, note: str
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Use one photograph of a dish, and say whether its seeds can be counted."""
|
|
77
|
+
entry = record["dishes"][dish]
|
|
78
|
+
entry["decision"] = _decision(entry["shots"][shot], suitable, note)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def clear_confirmation(record: dict, dish: int) -> None:
|
|
82
|
+
record["dishes"][dish].pop("decision", None)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def accept_remaining(record: dict) -> None:
|
|
86
|
+
"""Confirm the suggestion for every dish that has no confirmation yet."""
|
|
87
|
+
for dish in record["dishes"]:
|
|
88
|
+
if dish.get("decision") is not None:
|
|
89
|
+
continue
|
|
90
|
+
suggested = dish["shots"][0]
|
|
91
|
+
dish["decision"] = _decision(suggested, suggested["suitable"], "")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_verdict(value: object) -> bool:
|
|
95
|
+
"""Whether a value says the seeds can be counted, cannot, or left unsure."""
|
|
96
|
+
return value is True or value is False or value is None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _decision(shot: dict, suitable: bool | None, note: str) -> dict:
|
|
100
|
+
return {"chosen": shot["path"], "suitable": suitable, "note": note}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _is_record(record: object) -> bool:
|
|
104
|
+
if not isinstance(record, dict):
|
|
105
|
+
return False
|
|
106
|
+
dishes, skipped = record.get("dishes"), record.get("skipped")
|
|
107
|
+
return (
|
|
108
|
+
isinstance(dishes, list)
|
|
109
|
+
and all(_is_dish(dish) for dish in dishes)
|
|
110
|
+
and isinstance(skipped, list)
|
|
111
|
+
and all(_is_skipped(item) for item in skipped)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_dish(dish: object) -> bool:
|
|
116
|
+
if not isinstance(dish, dict):
|
|
117
|
+
return False
|
|
118
|
+
shots = dish.get("shots")
|
|
119
|
+
if not isinstance(shots, list) or not shots:
|
|
120
|
+
return False
|
|
121
|
+
if not all(_is_shot(shot) for shot in shots):
|
|
122
|
+
return False
|
|
123
|
+
return _is_decision(dish.get("decision"), {shot["path"] for shot in shots})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_shot(shot: object) -> bool:
|
|
127
|
+
if not isinstance(shot, dict):
|
|
128
|
+
return False
|
|
129
|
+
sharpness, reason = shot.get("sharpness"), shot.get("reason")
|
|
130
|
+
return (
|
|
131
|
+
_is_photograph(shot.get("path"))
|
|
132
|
+
and (sharpness is None or _is_number(sharpness))
|
|
133
|
+
and isinstance(shot.get("suitable"), bool)
|
|
134
|
+
and (reason is None or isinstance(reason, str))
|
|
135
|
+
and _is_alignment(shot.get("alignment"))
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _is_photograph(path: object) -> bool:
|
|
140
|
+
return isinstance(path, str) and Path(path).suffix.lower() in PHOTO_SUFFIXES
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_alignment(alignment: object) -> bool:
|
|
144
|
+
return (
|
|
145
|
+
isinstance(alignment, list)
|
|
146
|
+
and len(alignment) == 2
|
|
147
|
+
and all(
|
|
148
|
+
isinstance(row, list)
|
|
149
|
+
and len(row) == 3
|
|
150
|
+
and all(_is_number(value) for value in row)
|
|
151
|
+
for row in alignment
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_number(value: object) -> bool:
|
|
157
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_decision(decision: object, paths: set[str]) -> bool:
|
|
161
|
+
if decision is None:
|
|
162
|
+
return True
|
|
163
|
+
if not isinstance(decision, dict):
|
|
164
|
+
return False
|
|
165
|
+
return (
|
|
166
|
+
decision.get("chosen") in paths
|
|
167
|
+
and is_verdict(decision.get("suitable"))
|
|
168
|
+
and isinstance(decision.get("note"), str)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _is_skipped(item: object) -> bool:
|
|
173
|
+
return (
|
|
174
|
+
isinstance(item, dict)
|
|
175
|
+
and isinstance(item.get("path"), str)
|
|
176
|
+
and isinstance(item.get("reason"), str)
|
|
177
|
+
)
|