Address code review: refactor move_to_and_click retries, simplify wait_for_then_click, resolve conflicts with main

This commit is contained in:
abhijitgorde5-crypto
2026-09-07 23:55:04 +05:30
7 changed files with 804 additions and 424 deletions
+23 -2
View File
@@ -6,6 +6,20 @@ from selenium.common.exceptions import NoSuchElementException, StaleElementRefer
from selenium import webdriver
class ElementNotReady(NoSuchElementException):
"""The element is in the page but not usable yet.
A section this market does not ship and a section that has not finished
hydrating both reach the caller as NoSuchElementException, which is why a
run could report "not available in this UI variant" for something that was
on screen. They need different messages and different next steps, so the
second case gets its own type.
Subclassed rather than separate, so every existing `except
NoSuchElementException` keeps catching it.
"""
class Labels:
"""Visible labels the selectors match on.
@@ -45,7 +59,9 @@ class ElementSelectionUtils:
pick the copy that is visible and actually has content.
Anything the current variant does not ship raises NoSuchElementException so
the caller can skip that task instead of aborting the whole run.
the caller can skip that task instead of aborting the whole run. Something
that is present but not usable yet raises ElementNotReady instead, because
skipping it is the wrong answer and so is the message that goes with it.
"""
def __init__(self, driver: webdriver.Edge):
@@ -66,6 +82,11 @@ class ElementSelectionUtils:
their `.text` is empty, so returning one produces silent no-ops further
up. Raising instead lets the caller's WebDriverWait retry while the page
finishes hydrating.
The two failures are not the same finding. No element with the id means
this variant does not ship the section. An id that is there but has no
usable copy means it is still rendering, so that one raises
ElementNotReady.
"""
matches = self.driver.find_elements(By.ID, element_id)
@@ -79,7 +100,7 @@ class ElementSelectionUtils:
except StaleElementReferenceException:
continue
raise NoSuchElementException(
raise ElementNotReady(
f"{element_id!r} is present but no visible copy has content yet"
)
+15 -6
View File
@@ -8,7 +8,11 @@ import rewards_tasks
from selenium import webdriver
from selenium.common.exceptions import SessionNotCreatedException
HEADLESS = os.environ.get("REWARDS_HEADLESS", "").strip().lower() in ("1", "true", "yes")
HEADLESS = os.environ.get("REWARDS_HEADLESS", "").strip().lower() in (
"1",
"true",
"yes",
)
logger = logging.getLogger(__name__)
@@ -17,7 +21,7 @@ def build_options(account: accounts.Account) -> webdriver.EdgeOptions:
options = webdriver.EdgeOptions()
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option('useAutomationExtension', False)
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument(f"--user-data-dir={account.user_data_dir}")
options.add_argument(f"--profile-directory={account.profile_name}")
@@ -45,7 +49,9 @@ def run_account(account: accounts.Account) -> bool:
# the profile nor the other window.
logger.error("[FAIL] %s: could not start Edge with this profile.", account.name)
logger.error(" profile directory: %s", account.user_data_dir)
logger.error(" The usual cause is that this profile is already open in another")
logger.error(
" The usual cause is that this profile is already open in another"
)
logger.error(" Edge window, including one left over from a previous run.")
logger.error(" driver said: %s", log_utils.exception_summary(exc))
@@ -63,7 +69,8 @@ def run_account(account: accounts.Account) -> bool:
# own error, and the process it is meant to end is dead anyway.
logger.warning(
"%s: the driver did not shut down cleanly: %s",
account.name, log_utils.exception_summary(exc)
account.name,
log_utils.exception_summary(exc),
)
return True
@@ -97,8 +104,10 @@ def main() -> int:
except Exception as exc:
logger.error(
"[FAIL] %s: %s: %s",
account.name, type(exc).__name__, log_utils.exception_summary(exc),
exc_info=logger.isEnabledFor(logging.DEBUG)
account.name,
type(exc).__name__,
log_utils.exception_summary(exc),
exc_info=logger.isEnabledFor(logging.DEBUG),
)
if len(configured) > 1:
+322 -330
View File
@@ -43,10 +43,10 @@ MAX_BACKOFF = 300
session = requests.Session()
session.headers.update({
"User-Agent": (
"RandomVisualSearchImage/1.1 "
"(contact: 12345rfdz@gmail.com)"
)
"User-Agent": (
"RandomVisualSearchImage/1.1 "
"(contact: 12345rfdz@gmail.com)"
)
})
@@ -55,17 +55,17 @@ session.headers.update({
# ============================================================
def clean_url(url):
"""Remove query parameters from Wikimedia URLs."""
"""Remove query parameters from Wikimedia URLs."""
parts = urlsplit(url)
parts = urlsplit(url)
return urlunsplit((
parts.scheme,
parts.netloc,
parts.path,
"",
"",
))
return urlunsplit((
parts.scheme,
parts.netloc,
parts.path,
"",
"",
))
# ============================================================
@@ -73,32 +73,32 @@ def clean_url(url):
# ============================================================
def wait_after_429(response, attempt):
"""Wait according to Wikimedia's Retry-After header."""
"""Wait according to Wikimedia's Retry-After header."""
retry_after = response.headers.get("Retry-After")
retry_after = response.headers.get("Retry-After")
if retry_after:
try:
wait_time = int(retry_after)
except ValueError:
wait_time = min(
2 ** attempt,
MAX_BACKOFF,
)
else:
wait_time = min(
2 ** attempt,
MAX_BACKOFF,
)
if retry_after:
try:
wait_time = int(retry_after)
except ValueError:
wait_time = min(
2 ** attempt,
MAX_BACKOFF,
)
else:
wait_time = min(
2 ** attempt,
MAX_BACKOFF,
)
wait_time = max(5, wait_time)
wait_time = max(5, wait_time)
print(
f"Rate limited. Waiting "
f"{wait_time} seconds..."
)
print(
f"Rate limited. Waiting "
f"{wait_time} seconds..."
)
time.sleep(wait_time)
time.sleep(wait_time)
# ============================================================
@@ -106,51 +106,51 @@ def wait_after_429(response, attempt):
# ============================================================
def download_image(url):
"""Download image bytes from Wikimedia."""
"""Download image bytes from Wikimedia."""
url = clean_url(url)
url = clean_url(url)
try:
response = session.get(
url,
timeout=30,
allow_redirects=True,
)
try:
response = session.get(
url,
timeout=30,
allow_redirects=True,
)
except (requests.RequestException, ConnectionResetError, OSError) as e:
print(f"Download failed: {e}")
return None
except (requests.RequestException, ConnectionResetError, OSError) as e:
print(f"Download failed: {e}")
return None
if response.status_code == 429:
wait_after_429(response, 1)
return None
if response.status_code == 429:
wait_after_429(response, 1)
return None
if response.status_code == 403:
print("Wikimedia returned 403 Forbidden.")
return None
if response.status_code == 403:
print("Wikimedia returned 403 Forbidden.")
return None
try:
response.raise_for_status()
except requests.RequestException as e:
print(f"HTTP error: {e}")
return None
try:
response.raise_for_status()
except requests.RequestException as e:
print(f"HTTP error: {e}")
return None
content_type = response.headers.get(
"Content-Type",
"",
).lower()
content_type = response.headers.get(
"Content-Type",
"",
).lower()
if not content_type.startswith("image/"):
print(
f"Not an image: {content_type}"
)
return None
if not content_type.startswith("image/"):
print(
f"Not an image: {content_type}"
)
return None
if not response.content:
print("Downloaded image is empty.")
return None
if not response.content:
print("Downloaded image is empty.")
return None
return response.content
return response.content
# ============================================================
@@ -158,65 +158,65 @@ def download_image(url):
# ============================================================
def convert_to_jpeg(image_data):
"""Convert downloaded image bytes to JPEG."""
"""Convert downloaded image bytes to JPEG."""
try:
with Image.open(
io.BytesIO(image_data)
) as image:
try:
with Image.open(
io.BytesIO(image_data)
) as image:
# JPEG does not support alpha (transparency).
# If the image has transparency (RGBA or LA), paste it over a white background.
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
background = Image.new("RGB", image.size, (255, 255, 255))
if image.mode == "P":
image = image.convert("RGBA")
background.paste(image, mask=image.split()[-1])
jpeg_image = background
else:
jpeg_image = image.convert("RGB")
# JPEG does not support alpha (transparency).
# If the image has transparency (RGBA or LA), paste it over a white background.
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
background = Image.new("RGB", image.size, (255, 255, 255))
if image.mode == "P":
image = image.convert("RGBA")
background.paste(image, mask=image.split()[-1])
jpeg_image = background
else:
jpeg_image = image.convert("RGB")
output = io.BytesIO()
output = io.BytesIO()
jpeg_image.save(
output,
format="JPEG",
quality=90,
optimize=True,
)
jpeg_image.save(
output,
format="JPEG",
quality=90,
optimize=True,
)
return output.getvalue()
return output.getvalue()
except Exception as e:
print(
f"JPEG conversion failed: {e}"
)
return None
except Exception as e:
print(
f"JPEG conversion failed: {e}"
)
return None
def generate_fallback_image():
"""Generate a synthetic local JPEG image using PIL as a fallback."""
print("[INFO] Generating synthetic local fallback image for visual search...")
from PIL import ImageDraw
import random
"""Generate a synthetic local JPEG image using PIL as a fallback."""
print("[INFO] Generating synthetic local fallback image for visual search...")
from PIL import ImageDraw
import random
img = Image.new("RGB", (800, 600), color=(random.randint(50, 200), random.randint(50, 200), random.randint(50, 200)))
draw = ImageDraw.Draw(img)
for _ in range(10):
x0 = random.randint(0, 700)
y0 = random.randint(0, 500)
x1 = x0 + random.randint(50, 200)
y1 = y0 + random.randint(50, 200)
fill = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
draw.rectangle([x0, y0, x1, y1], fill=fill)
img = Image.new("RGB", (800, 600), color=(random.randint(50, 200), random.randint(50, 200), random.randint(50, 200)))
draw = ImageDraw.Draw(img)
for _ in range(10):
x0 = random.randint(0, 700)
y0 = random.randint(0, 500)
x1 = x0 + random.randint(50, 200)
y1 = y0 + random.randint(50, 200)
fill = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
draw.rectangle([x0, y0, x1, y1], fill=fill)
output = io.BytesIO()
img.save(output, format="JPEG", quality=90)
jpeg_data = output.getvalue()
output = io.BytesIO()
img.save(output, format="JPEG", quality=90)
jpeg_data = output.getvalue()
OUTPUT_FILE.write_bytes(jpeg_data)
print(f"Saved fallback image to {OUTPUT_FILE.absolute()}")
return {"title": "Fallback Synthetic Image", "width": 800, "height": 600}
OUTPUT_FILE.write_bytes(jpeg_data)
print(f"Saved fallback image to {OUTPUT_FILE.absolute()}")
return {"title": "Fallback Synthetic Image", "width": 800, "height": 600}
# ============================================================
@@ -225,262 +225,262 @@ def generate_fallback_image():
def get_random_image():
for attempt in range(
1,
MAX_ATTEMPTS + 1,
):
for attempt in range(
1,
MAX_ATTEMPTS + 1,
):
if attempt > 1:
time.sleep(REQUEST_DELAY)
if attempt > 1:
time.sleep(REQUEST_DELAY)
print(
f"\nAttempt "
f"{attempt}/{MAX_ATTEMPTS}"
)
print(
f"\nAttempt "
f"{attempt}/{MAX_ATTEMPTS}"
)
params = {
"action": "query",
"format": "json",
params = {
"action": "query",
"format": "json",
"generator": "random",
"grnnamespace": 6,
"grnlimit": 1,
"generator": "random",
"grnnamespace": 6,
"grnlimit": 1,
"prop": "imageinfo",
"prop": "imageinfo",
"iiprop": (
"url|size|mime|dimensions"
),
"iiprop": (
"url|size|mime|dimensions"
),
"iiurlwidth": THUMBNAIL_WIDTH,
}
"iiurlwidth": THUMBNAIL_WIDTH,
}
try:
response = session.get(
API_URL,
params=params,
timeout=20,
)
try:
response = session.get(
API_URL,
params=params,
timeout=20,
)
except (requests.RequestException, ConnectionResetError, OSError) as e:
print(f"API request failed: {e}")
continue
except (requests.RequestException, ConnectionResetError, OSError) as e:
print(f"API request failed: {e}")
continue
if response.status_code == 429:
wait_after_429(
response,
attempt,
)
continue
if response.status_code == 429:
wait_after_429(
response,
attempt,
)
continue
try:
response.raise_for_status()
data = response.json()
try:
response.raise_for_status()
data = response.json()
except (
requests.RequestException,
ValueError,
) as e:
print(f"API error: {e}")
continue
except (
requests.RequestException,
ValueError,
) as e:
print(f"API error: {e}")
continue
pages = (
data
.get("query", {})
.get("pages", {})
)
pages = (
data
.get("query", {})
.get("pages", {})
)
if not pages:
print("No page returned.")
continue
if not pages:
print("No page returned.")
continue
page = next(
iter(pages.values())
)
page = next(
iter(pages.values())
)
title = page.get(
"title",
"Unknown",
)
title = page.get(
"title",
"Unknown",
)
imageinfo = page.get(
"imageinfo"
)
imageinfo = page.get(
"imageinfo"
)
if not imageinfo:
print(
"No image information."
)
continue
if not imageinfo:
print(
"No image information."
)
continue
info = imageinfo[0]
info = imageinfo[0]
mime = info.get(
"mime",
"",
)
mime = info.get(
"mime",
"",
)
width = info.get(
"width",
0,
)
width = info.get(
"width",
0,
)
height = info.get(
"height",
0,
)
height = info.get(
"height",
0,
)
size = info.get(
"size",
0,
)
size = info.get(
"size",
0,
)
thumbnail_url = info.get(
"thumburl"
)
thumbnail_url = info.get(
"thumburl"
)
original_url = info.get(
"url"
)
original_url = info.get(
"url"
)
if mime not in {
"image/jpeg",
"image/png",
"image/webp",
}:
print(
f"Skipping unsupported type: "
f"{mime}"
)
continue
if mime not in {
"image/jpeg",
"image/png",
"image/webp",
}:
print(
f"Skipping unsupported type: "
f"{mime}"
)
continue
if width < MIN_WIDTH or height < MIN_HEIGHT:
print(
f"Skipping small image: "
f"{width}x{height}"
)
continue
if width < MIN_WIDTH or height < MIN_HEIGHT:
print(
f"Skipping small image: "
f"{width}x{height}"
)
continue
if size > MAX_FILE_SIZE:
print(
f"Skipping large image: "
f"{size / 1024 / 1024:.1f} MB"
)
continue
if size > MAX_FILE_SIZE:
print(
f"Skipping large image: "
f"{size / 1024 / 1024:.1f} MB"
)
continue
if not thumbnail_url:
print("No thumbnail URL.")
continue
if not thumbnail_url:
print("No thumbnail URL.")
continue
print(f"Found: {title}")
print(
f"Size: {width}x{height}"
)
print(f"Found: {title}")
print(
f"Size: {width}x{height}"
)
image_data = download_image(
thumbnail_url
)
image_data = download_image(
thumbnail_url
)
if image_data is None and original_url:
print(
"Trying original..."
)
if image_data is None and original_url:
print(
"Trying original..."
)
image_data = download_image(
original_url
)
image_data = download_image(
original_url
)
if image_data is None:
print(
"Couldn't download image."
)
continue
if image_data is None:
print(
"Couldn't download image."
)
continue
print("Converting to JPEG...")
print("Converting to JPEG...")
jpeg_data = convert_to_jpeg(
image_data
)
jpeg_data = convert_to_jpeg(
image_data
)
if jpeg_data is None:
continue
if jpeg_data is None:
continue
try:
OUTPUT_FILE.write_bytes(
jpeg_data
)
try:
OUTPUT_FILE.write_bytes(
jpeg_data
)
except OSError as e:
print(
f"Couldn't save image: {e}"
)
continue
except OSError as e:
print(
f"Couldn't save image: {e}"
)
continue
metadata = {
"title": title,
"source": "Wikimedia Commons",
"output_format": "JPEG",
metadata = {
"title": title,
"source": "Wikimedia Commons",
"output_format": "JPEG",
"width": width,
"height": height,
"width": width,
"height": height,
"original_mime": mime,
"original_mime": mime,
"original_size": size,
"original_size": size,
"jpeg_size": len(
jpeg_data
),
"jpeg_size": len(
jpeg_data
),
"original_url": (
clean_url(original_url)
if original_url
else None
),
"original_url": (
clean_url(original_url)
if original_url
else None
),
"thumbnail_url": (
clean_url(thumbnail_url)
if thumbnail_url
else None
),
}
"thumbnail_url": (
clean_url(thumbnail_url)
if thumbnail_url
else None
),
}
try:
METADATA_FILE.write_text(
json.dumps(
metadata,
indent=4,
ensure_ascii=False,
),
encoding="utf-8",
)
try:
METADATA_FILE.write_text(
json.dumps(
metadata,
indent=4,
ensure_ascii=False,
),
encoding="utf-8",
)
except OSError as e:
print(
f"Warning: couldn't save "
f"metadata: {e}"
)
except OSError as e:
print(
f"Warning: couldn't save "
f"metadata: {e}"
)
print()
print("=" * 50)
print("SUCCESS")
print("=" * 50)
print(
f"Image: "
f"{OUTPUT_FILE.absolute()}"
)
print(
f"Size: "
f"{len(jpeg_data) / 1024:.1f} KB"
)
print(
f"Source: {title}"
)
print()
print("=" * 50)
print("SUCCESS")
print("=" * 50)
print(
f"Image: "
f"{OUTPUT_FILE.absolute()}"
)
print(
f"Size: "
f"{len(jpeg_data) / 1024:.1f} KB"
)
print(
f"Source: {title}"
)
return metadata
return metadata
print("[WARNING] Could not download image from Wikimedia Commons. Generating local fallback image.")
return generate_fallback_image()
print("[WARNING] Could not download image from Wikimedia Commons. Generating local fallback image.")
return generate_fallback_image()
# ============================================================
@@ -488,12 +488,4 @@ def get_random_image():
# ============================================================
if __name__ == "__main__":
get_random_image()
# ============================================================
# MAIN
# ============================================================
if __name__ == "__main__":
get_random_image()
get_random_image()
+106 -23
View File
@@ -20,10 +20,62 @@ VISUAL_SEARCH_IMAGE_PATH = os.path.abspath("visual_search.jpg")
logger = logging.getLogger(__name__)
class ElementNeverAppeared(TimeoutException):
"""A wait expired without the element ever being in the page.
WebDriverWait reports only that the wait ran out, so a section this market
does not ship and a section that was on screen and slow arrived as the same
TimeoutException. Reporting both as "not available in this UI variant" was
wrong for the second one, which is what #52 describes.
Subclassed from TimeoutException so the handlers that already wait on a
control being absent, claim_bonus_points and complete_bing_daily_set, keep
working unchanged.
"""
def task_failure_report(exc: BaseException) -> tuple[str, str]:
"""The tag and the reason a failed task is reported with.
Absence and an expired wait need different next steps. A section this market
does not ship is nothing to act on, so it stays a [SKIP]. A section that was
on the page and never became usable may have left points behind, so it is
reported as a failure instead of being folded into the same sentence.
Ordered from the most specific case outwards, not by exception hierarchy:
ElementNeverAppeared is a TimeoutException and ElementNotReady is a
NoSuchElementException, so each has to be tested before the class it
refines.
"""
unavailable = f"not available in this UI variant ({type(exc).__name__})"
if isinstance(exc, ElementNeverAppeared):
return "SKIP", unavailable
if isinstance(exc, (element_selectors.ElementNotReady, TimeoutException)):
return "FAIL", f"on the page but not ready in time ({type(exc).__name__})"
if isinstance(exc, NoSuchElementException):
return "SKIP", unavailable
return "FAIL", f"{type(exc).__name__}: {log_utils.exception_summary(exc)}"
class RewardsTaskUtils:
def __init__(self, driver: webdriver.Edge):
self.driver = driver
# Set headers to spoof the rewards app for the rewards only quests
self.driver.execute_cdp_cmd("Network.enable", {})
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0 MSRewards/Desktop/1.1.0",
"X-Rewards-Source": "msrewards-desktop",
}
self.driver.execute_cdp_cmd("Network.setExtraHTTPHeaders", {"headers": headers})
self.driver.get("https://rewards.bing.com/")
self.tab_utils = tab_utils.TabUtils(driver)
@@ -48,15 +100,45 @@ class RewardsTaskUtils:
return self.driver.find_element(By.XPATH, xpath)
def wait_for_element(self, element_getter: Callable[[], WebElement | list[WebElement]], timeout: int = 10) -> WebElement | list[WebElement]:
# Keep the last reason the getter gave. Without it a wait that expires
# cannot say whether the element was missing the whole time or was on
# the page and not ready, and those are reported differently.
last_error: BaseException | None = None
def condition(_: webdriver.Edge):
nonlocal last_error
try:
element_or_elements = element_getter()
except Exception as exc:
# Exception rather than a bare except, so Ctrl+C during a
# getter ends the run instead of being retried away.
last_error = exc
return element_or_elements
except:
return False
return WebDriverWait(self.driver, timeout).until(condition)
last_error = None
return element_or_elements
try:
return WebDriverWait(self.driver, timeout).until(condition)
except TimeoutException:
# A falsy return means the getter found something and rejected it,
# and ElementNotReady means it was there but still rendering. Only
# a plain NoSuchElementException every time means it was never
# there at all.
never_there = (
isinstance(last_error, NoSuchElementException)
and not isinstance(last_error, element_selectors.ElementNotReady)
)
if not never_there:
raise
raise ElementNeverAppeared(
f"nothing matched during the {timeout}s wait: {log_utils.exception_summary(last_error)}"
) from last_error
def switch_to_earn_page(self):
self.move_to_and_click(self.elements.get_earn_tab())
@@ -65,25 +147,25 @@ class RewardsTaskUtils:
self.move_to_and_click(self.elements.get_dashboard_tab())
def move_to_and_click(self, elem_or_getter: WebElement | Callable[[], WebElement], retries: int = 3):
for attempt in range(retries):
try:
if callable(elem_or_getter):
if callable(elem_or_getter):
for attempt in range(retries):
try:
target_elem = elem_or_getter()
else:
target_elem = elem_or_getter
self.mouse.move_to_element(target_elem)
self.mouse.human_like_click()
return
except StaleElementReferenceException as exc:
if attempt == retries - 1:
raise exc
print(f"[WARNING] StaleElementReferenceException during click attempt {attempt + 1}/{retries}, retrying...")
time.sleep(0.5)
self.mouse.move_to_element(target_elem)
self.mouse.human_like_click()
return
except StaleElementReferenceException as exc:
if attempt == retries - 1:
raise exc
logger.warning("StaleElementReferenceException during click attempt %d/%d, retrying...", attempt + 1, retries)
time.sleep(0.5)
else:
self.mouse.move_to_element(elem_or_getter)
self.mouse.human_like_click()
def wait_for_then_click(self, element_getter: Callable[[], WebElement], timeout: int = 10):
elem = self.wait_for_element(element_getter, timeout)
self.move_to_and_click(element_getter if callable(element_getter) else elem)
self.wait_for_element(element_getter, timeout)
self.move_to_and_click(element_getter)
def complete_bing_daily_set(self, expected_activities: int = 3):
self.switch_to_earn_page()
@@ -347,11 +429,12 @@ class RewardsTaskUtils:
try:
step()
logger.info("[OK] %s", name)
except (NoSuchElementException, TimeoutException) as exc:
logger.warning("[SKIP] %s: not available in this UI variant (%s)", name, type(exc).__name__)
except Exception as exc:
logger.error(
"[FAIL] %s: %s: %s", name, type(exc).__name__, log_utils.exception_summary(exc),
tag, reason = task_failure_report(exc)
logger.log(
logging.WARNING if tag == "SKIP" else logging.ERROR,
"[%s] %s: %s", tag, name, reason,
exc_info=logger.isEnabledFor(logging.DEBUG)
)
+63 -63
View File
@@ -19,87 +19,87 @@ Point = tuple[int, int]
def make_paths() -> tuple[callable, callable]:
"""Return independent paths with the same start and end points."""
start = (150, 535)
end = (950, 535)
base_path = get_bezier_path(start, end, intermediate_radius_interval=(150, 210))
distorted_path = get_distorted_bezier_path(
start,
end,
intermediate_radius_interval=(150, 210),
distortion_zone_time_length=0.08,
distortion_frequency=1.0,
deviation_interval=(10, 18),
)
"""Return independent paths with the same start and end points."""
start = (150, 535)
end = (950, 535)
base_path = get_bezier_path(start, end, intermediate_radius_interval=(150, 210))
distorted_path = get_distorted_bezier_path(
start,
end,
intermediate_radius_interval=(150, 210),
distortion_zone_time_length=0.08,
distortion_frequency=1.0,
deviation_interval=(10, 18),
)
return base_path, distorted_path
return base_path, distorted_path
def sample_path(path: callable, steps: int = 360) -> list[Point]:
return [
(round(point[0]), round(point[1]))
for point in (path(index / (steps - 1)) for index in range(steps))
]
return [
(round(point[0]), round(point[1]))
for point in (path(index / (steps - 1)) for index in range(steps))
]
def draw_label(screen: pygame.Surface, font: pygame.font.Font, text: str, position: tuple[int, int], color: tuple[int, int, int]) -> None:
screen.blit(font.render(text, True, color), position)
screen.blit(font.render(text, True, color), position)
def main() -> None:
pygame.init()
screen = pygame.display.set_mode(WINDOW_SIZE)
pygame.display.set_caption("Bezier Path Distortion")
clock = pygame.time.Clock()
title_font = pygame.font.SysFont("Segoe UI", 28, bold=True)
body_font = pygame.font.SysFont("Segoe UI", 19)
button_font = pygame.font.SysFont("Segoe UI", 18, bold=True)
pygame.init()
screen = pygame.display.set_mode(WINDOW_SIZE)
pygame.display.set_caption("Bezier Path Distortion")
clock = pygame.time.Clock()
title_font = pygame.font.SysFont("Segoe UI", 28, bold=True)
body_font = pygame.font.SysFont("Segoe UI", 19)
button_font = pygame.font.SysFont("Segoe UI", 18, bold=True)
base_path, distorted_path = make_paths()
base_points = sample_path(base_path)
distorted_points = sample_path(distorted_path)
show_distorted = False
button = pygame.Rect(405, 595, 290, 52)
base_path, distorted_path = make_paths()
base_points = sample_path(base_path)
distorted_points = sample_path(distorted_path)
show_distorted = False
button = pygame.Rect(405, 595, 290, 52)
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
if button.collidepoint(event.pos):
show_distorted = not show_distorted
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
if button.collidepoint(event.pos):
show_distorted = not show_distorted
screen.fill(BACKGROUND)
draw_label(screen, title_font, "Bezier path comparison", (38, 28), PATH_COLOR)
draw_label(
screen,
body_font,
"The red path adds temporary offsets to the same underlying curve.",
(40, 70),
(91, 97, 102),
)
screen.fill(BACKGROUND)
draw_label(screen, title_font, "Bezier path comparison", (38, 28), PATH_COLOR)
draw_label(
screen,
body_font,
"The red path adds temporary offsets to the same underlying curve.",
(40, 70),
(91, 97, 102),
)
pygame.draw.lines(screen, (184, 188, 190), False, base_points, 1)
pygame.draw.lines(screen, DISTORTED_PATH_COLOR if show_distorted else PATH_COLOR, False, distorted_points if show_distorted else base_points, 4)
pygame.draw.circle(screen, POINT_COLOR, base_points[0], 10)
pygame.draw.circle(screen, POINT_COLOR, base_points[-1], 10)
pygame.draw.lines(screen, (184, 188, 190), False, base_points, 1)
pygame.draw.lines(screen, DISTORTED_PATH_COLOR if show_distorted else PATH_COLOR, False, distorted_points if show_distorted else base_points, 4)
pygame.draw.circle(screen, POINT_COLOR, base_points[0], 10)
pygame.draw.circle(screen, POINT_COLOR, base_points[-1], 10)
draw_label(screen, body_font, "A", (base_points[0][0] - 8, base_points[0][1] + 18), PATH_COLOR)
draw_label(screen, body_font, "B", (base_points[-1][0] - 8, base_points[-1][1] + 18), PATH_COLOR)
draw_label(screen, body_font, "DISTORTED" if show_distorted else "UNDISTORTED", (20, 535), DISTORTED_PATH_COLOR if show_distorted else PATH_COLOR)
draw_label(screen, body_font, "A", (base_points[0][0] - 8, base_points[0][1] + 18), PATH_COLOR)
draw_label(screen, body_font, "B", (base_points[-1][0] - 8, base_points[-1][1] + 18), PATH_COLOR)
draw_label(screen, body_font, "DISTORTED" if show_distorted else "UNDISTORTED", (20, 535), DISTORTED_PATH_COLOR if show_distorted else PATH_COLOR)
button_color = BUTTON_HOVER_COLOR if button.collidepoint(pygame.mouse.get_pos()) else BUTTON_COLOR
pygame.draw.rect(screen, button_color, button, border_radius=7)
button_text = "Show undistorted path" if show_distorted else "Show distorted path"
text_surface = button_font.render(button_text, True, BUTTON_TEXT_COLOR)
screen.blit(text_surface, text_surface.get_rect(center=button.center))
button_color = BUTTON_HOVER_COLOR if button.collidepoint(pygame.mouse.get_pos()) else BUTTON_COLOR
pygame.draw.rect(screen, button_color, button, border_radius=7)
button_text = "Show undistorted path" if show_distorted else "Show distorted path"
text_surface = button_font.render(button_text, True, BUTTON_TEXT_COLOR)
screen.blit(text_surface, text_surface.get_rect(center=button.center))
pygame.display.flip()
clock.tick(60)
pygame.display.flip()
clock.tick(60)
pygame.quit()
pygame.quit()
if __name__ == "__main__":
main()
main()